forked from akai/readest
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f385c2bf02 | |||
| 2c6535450e | |||
| 83cb7166fb | |||
| b40654a671 | |||
| 116b831102 | |||
| 17336d7ac9 | |||
| 447eb09272 | |||
| a074542e96 | |||
| 9be9bc8a34 | |||
| d7ccbbcaa2 | |||
| b581251b58 | |||
| f63ecba5fe | |||
| bd549d2a0f | |||
| 419db86a4d | |||
| 54e8798468 | |||
| 69276b6beb | |||
| ed177530ac | |||
| 3be4a26b14 | |||
| 3dbb41fca0 | |||
| ace59fb34c | |||
| 61067dba93 | |||
| 97a0592a43 | |||
| d15e661038 | |||
| c69d58a270 | |||
| 960a56cc6f | |||
| eb346b46b2 | |||
| beaa9e42ce |
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: [a handful of words describing the FR]'
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty completing a task? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you'd like to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any additional context or screenshots about the feature request here.
|
||||
@@ -96,9 +96,6 @@ jobs:
|
||||
- name: copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: fix dynamic route for Next.js, see https://github.com/vercel/next.js/discussions/55393
|
||||
run: rimraf "apps/readest-app/src/app/reader/[ids]"
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.config.os == 'ubuntu-latest'
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Deploy to vercel on merge
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-args: '--prod'
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Create vercel preview URL on pull request
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
permissions:
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
id: vercel-deploy
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
- name: preview-url
|
||||
run: |
|
||||
echo ${{ steps.vercel-deploy.outputs.preview-url }}
|
||||
@@ -39,18 +39,19 @@
|
||||
|
||||
<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. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -60,12 +61,11 @@
|
||||
| **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. | 🛠 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
| **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. | 🔄 |
|
||||
@@ -162,7 +162,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
|
||||
|
||||
<a href="https://github.com/chrox/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="50" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
|
||||
<img width="100" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
|
||||
@@ -195,6 +195,6 @@ The following JavaScript libraries are bundled in this software:
|
||||
[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-discord]: https://discord.gg/gntyVNk3BJ
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
PDFJS_BUILD_PATH=../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build
|
||||
PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
|
||||
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
|
||||
|
||||
NEXT_PUBLIC_DEV_SUPABASE_URL=https://gxkhxxxeapexynpouyjz.supabase.co
|
||||
NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4a2h4eHhlYXBleHlucG91eWp6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0MzAwNTksImV4cCI6MjA1MDAwNjA1OX0.jhinkQsimQoidsg_U59YD5ROw4PmMJQNKuyXbr4TiQA
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -12,10 +12,10 @@
|
||||
"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-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-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
||||
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/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",
|
||||
@@ -28,45 +28,50 @@
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.47.7",
|
||||
"@tauri-apps/api": "2.1.1",
|
||||
"@tauri-apps/plugin-cli": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.1",
|
||||
"@tauri-apps/plugin-fs": "^2.0.3",
|
||||
"@tauri-apps/plugin-http": "^2.0.1",
|
||||
"@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",
|
||||
"@tauri-apps/plugin-cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||
"@tauri-apps/plugin-http": "^2.2.0",
|
||||
"@tauri-apps/plugin-log": "^2.2.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "~2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.3.0",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cssbeautify": "^0.3.1",
|
||||
"epubjs": "^0.3.93",
|
||||
"foliate-js": "workspace:*",
|
||||
"js-md5": "^0.8.3",
|
||||
"next": "15.0.3",
|
||||
"next": "15.1.0",
|
||||
"posthog-js": "^1.194.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.1.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cpx": "^1.5.0",
|
||||
"cross-var": "^1.1.0",
|
||||
"cpx2": "^8.0.0",
|
||||
"daisyui": "^4.12.14",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"mkdirp": "^3.0.1",
|
||||
"node-env-run": "^4.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
|
||||
Generated
+250
-246
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ tauri-plugin-http = "2.0.3"
|
||||
tauri-plugin-devtools = "2.0.0"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-oauth = "2"
|
||||
[patch.crates-io]
|
||||
tauri = { path = "../../../packages/tauri/crates/tauri" }
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"cli:default"
|
||||
"cli:default",
|
||||
"oauth:allow-start",
|
||||
"oauth:allow-cancel"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ mod tauri_traffic_light_positioner_plugin;
|
||||
use tauri::TitleBarStyle;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{command, Window};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_dialog;
|
||||
use tauri_plugin_fs::FsExt;
|
||||
use tauri_plugin_oauth::start;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::Listener;
|
||||
@@ -58,10 +60,22 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn start_server(window: Window) -> Result<u16, String> {
|
||||
start(move |url| {
|
||||
// Because of the unprotected localhost port, you must verify the URL here.
|
||||
// Preferebly send back only the token, or nothing at all if you can handle everything else in Rust.
|
||||
let _ = window.emit("redirect_uri", url);
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[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_oauth::init())
|
||||
.invoke_handler(tauri::generate_handler![start_server])
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
@@ -115,7 +129,6 @@ pub fn run() {
|
||||
)?;
|
||||
}
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("")
|
||||
.inner_size(800.0, 600.0)
|
||||
.resizable(true)
|
||||
.maximized(true);
|
||||
@@ -123,10 +136,10 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
let win_builder = win_builder
|
||||
.decorations(true)
|
||||
.title_bar_style(TitleBarStyle::Overlay);
|
||||
.title_bar_style(TitleBarStyle::Overlay).title("");
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let win_builder = win_builder.decorations(false).transparent(true);
|
||||
let win_builder = win_builder.decorations(false).transparent(true).title("Readest");
|
||||
|
||||
win_builder.build().unwrap();
|
||||
// let win = win_builder.build().unwrap();
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com https://db.onlinewebfonts.com",
|
||||
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost",
|
||||
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
|
||||
@@ -23,7 +23,7 @@
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": {
|
||||
"allow": ["$RESOURCE/**", "$DOCUMENT/**/*", "$APPDATA/**/*", "$TEMP/**/*"],
|
||||
"allow": ["$RESOURCE/**", "$APPDATA/**/*", "$TEMP/**/*"],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const hash = window.location.hash || '';
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const next = params.get('next') ?? '/';
|
||||
|
||||
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
const router = useRouter();
|
||||
useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
router.push('/auth');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className='bg-base-200/50 text-base-content 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'>Authentication Error</h1>
|
||||
<p className='mb-5'>
|
||||
Something went wrong during the authentication process. Please try again.
|
||||
</p>
|
||||
<p className='mb-5'>You will be redirected to the login page shortly...</p>
|
||||
<button className='btn btn-primary rounded-xl' onClick={() => router.push('/auth')}>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Auth } from '@supabase/auth-ui-react';
|
||||
import { ThemeSupa } from '@supabase/auth-ui-shared';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
import { FaApple } from 'react-icons/fa';
|
||||
import { VscAzure } from 'react-icons/vsc';
|
||||
import { FaGithub } from 'react-icons/fa';
|
||||
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
|
||||
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
|
||||
|
||||
interface ProviderLoginProp {
|
||||
provider: OAuthProvider;
|
||||
handleSignIn: (provider: OAuthProvider) => void;
|
||||
Icon: React.ElementType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSignIn(provider)}
|
||||
className={clsx(
|
||||
'mb-2 flex w-64 items-center justify-center rounded border p-2.5',
|
||||
'bg-base-100 border-gray-300 shadow-sm transition hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className='text-neutral-content/70 px-2 text-sm'>{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { isDarkMode } = useTheme();
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
const isOAuthServerRunning = useRef(false);
|
||||
|
||||
const signIn = async (provider: OAuthProvider) => {
|
||||
if (!supabase) {
|
||||
throw new Error('No backend connected');
|
||||
}
|
||||
supabase.auth.signOut();
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
skipBrowserRedirect: true,
|
||||
redirectTo: `http://localhost:${port}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return;
|
||||
}
|
||||
open(data.url);
|
||||
};
|
||||
|
||||
const startOAuthServer = async () => {
|
||||
try {
|
||||
const port = await start();
|
||||
setPort(port);
|
||||
console.log(`OAuth server started on port ${port}`);
|
||||
|
||||
await onUrl((url) => {
|
||||
console.log('Received OAuth URL:', url);
|
||||
const hashMatch = url.match(/#(.*)/);
|
||||
if (hashMatch) {
|
||||
const hash = hashMatch[1];
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const next = params.get('next') ?? '/';
|
||||
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
|
||||
}
|
||||
});
|
||||
|
||||
await onInvalidUrl((url) => {
|
||||
console.log('Received invalid OAuth URL:', url);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error starting OAuth server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopOAuthServer = async () => {
|
||||
try {
|
||||
if (port) {
|
||||
await cancel(port);
|
||||
console.log('OAuth server stopped');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping OAuth server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
if (isOAuthServerRunning.current) return;
|
||||
isOAuthServerRunning.current = true;
|
||||
|
||||
startOAuthServer();
|
||||
return () => {
|
||||
isOAuthServerRunning.current = false;
|
||||
stopOAuthServer();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { data: subscription } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (session?.access_token && session.user) {
|
||||
login(session.access_token, session.user);
|
||||
router.push('/library');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.subscription.unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router]);
|
||||
|
||||
return isTauriAppPlatform() ? (
|
||||
<div className='flex pt-11'>
|
||||
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
|
||||
<ProviderLogin
|
||||
provider='google'
|
||||
handleSignIn={signIn}
|
||||
Icon={FcGoogle}
|
||||
label='Sign in with Google'
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='apple'
|
||||
handleSignIn={signIn}
|
||||
Icon={FaApple}
|
||||
label='Sign in with Apple'
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='azure'
|
||||
handleSignIn={signIn}
|
||||
Icon={VscAzure}
|
||||
label='Sign in with Azure'
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='github'
|
||||
handleSignIn={signIn}
|
||||
Icon={FaGithub}
|
||||
label='Sign in with GitHub'
|
||||
/>
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={`http://localhost:${port}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={['google', 'apple', 'azure', 'github']}
|
||||
redirectTo='/auth/callback'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
@@ -1,14 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
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 description =
|
||||
'Discover Readest, the ultimate online ebook reader for immersive and organized reading. ' +
|
||||
'Enjoy seamless access to your digital library, powerful tools for highlighting, bookmarking, ' +
|
||||
'and note-taking, and support for multiple book views. ' +
|
||||
'Perfect for deep reading, analysis, and understanding. Explore now!';
|
||||
const previewImage = 'https://cdn.readest.com/images/open_graph_preview_read_now.png';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -34,13 +36,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name='twitter:description' content={description} />
|
||||
<meta name='twitter:image' content={previewImage} />
|
||||
</head>
|
||||
<CSPostHogProvider>
|
||||
<body>
|
||||
<EnvProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</EnvProvider>
|
||||
</body>
|
||||
</CSPostHogProvider>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,19 +26,19 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
||||
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
|
||||
if (booksGroup) {
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.lastUpdated = Math.max(acc[groupIndex]!.lastUpdated, book.lastUpdated);
|
||||
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
|
||||
} else {
|
||||
acc.push({
|
||||
name: book.group,
|
||||
books: [book],
|
||||
lastUpdated: book.lastUpdated,
|
||||
updatedAt: book.updatedAt,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
|
||||
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
};
|
||||
|
||||
interface BookshelfProps {
|
||||
@@ -56,7 +56,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [clickedImage, setClickedImage] = useState<string | null>(null);
|
||||
const [importBookUrl] = useState(searchParams.get('url') || '');
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
const { setLibrary } = useLibraryStore();
|
||||
|
||||
@@ -7,6 +7,9 @@ import { PiSelectionAllDuotone } from 'react-icons/pi';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { MdOutlineMenu } from 'react-icons/md';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
@@ -85,12 +88,21 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
/>
|
||||
<div className='flex h-full items-center'>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end mr-2'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdOutlineMenu size={16} />}
|
||||
>
|
||||
<SettingsMenu />
|
||||
</Dropdown>
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface BookMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const downloadReadest = () => {
|
||||
window.open(DOWNLOAD_READEST_URL, '_blank');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleUserLogin = () => {
|
||||
router.push('/auth');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleUserLogout = () => {
|
||||
logout();
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'];
|
||||
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-60 shadow-2xl'
|
||||
>
|
||||
{user ? (
|
||||
<MenuItem
|
||||
label={userDisplayName ? `Logged in as ${userDisplayName}` : 'Logged in'}
|
||||
labelClass='!max-w-40'
|
||||
icon={
|
||||
avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt='User Avatar'
|
||||
className='h-5 w-5 rounded-full'
|
||||
referrerPolicy='no-referrer'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
) : (
|
||||
<PiUserCircleCheck size={20} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<MenuItem label='Sign Out' noIcon onClick={handleUserLogout} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem
|
||||
label='Sign In'
|
||||
icon={<PiUserCircle size={20} />}
|
||||
onClick={handleUserLogin}
|
||||
></MenuItem>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label='Download Readest' onClick={downloadReadest} />}
|
||||
<MenuItem label='About Readest' onClick={showAboutReadest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMenu;
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { Book } from '@/types/book';
|
||||
import { getUserLang } from '@/utils/misc';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
import libraryEn from '@/data/demo/library.en.json';
|
||||
import libraryZh from '@/data/demo/library.zh.json';
|
||||
@@ -40,7 +41,7 @@ export const useDemoBooks = () => {
|
||||
};
|
||||
|
||||
const demoBooksFetchedFlag = localStorage.getItem('demoBooksFetched');
|
||||
if (!demoBooksFetchedFlag) {
|
||||
if (isWebAppPlatform() && !demoBooksFetchedFlag) {
|
||||
fetchDemoBooks();
|
||||
localStorage.setItem('demoBooksFetched', 'true');
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -21,10 +22,12 @@ import { useDemoBooks } from './hooks/useDemoBooks';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
library: libraryBooks,
|
||||
setLibrary,
|
||||
@@ -32,7 +35,7 @@ const LibraryPage = () => {
|
||||
clearOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
useTheme();
|
||||
const { setSettings } = useSettingsStore();
|
||||
const { setSettings, saveSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
@@ -73,6 +76,20 @@ const LibraryPage = () => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const initLogin = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
if (token && user) {
|
||||
if (!settings.keepLogin) {
|
||||
settings.keepLogin = true;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
}
|
||||
} else if (settings.keepLogin) {
|
||||
router.push('/auth');
|
||||
}
|
||||
};
|
||||
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
@@ -103,6 +120,7 @@ const LibraryPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
initLogin();
|
||||
initLibrary();
|
||||
return () => {
|
||||
clearOpenWithBooks();
|
||||
@@ -230,6 +248,7 @@ const LibraryPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<AboutWindow />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
|
||||
const toggleBookmark = () => {
|
||||
const { booknotes: bookmarks = [] } = config;
|
||||
const { location: cfi, sectionHref: href, range } = progress;
|
||||
const { location: cfi, range } = progress;
|
||||
if (!cfi) return;
|
||||
if (!isBookmarked) {
|
||||
setIsBookmarked(true);
|
||||
@@ -36,10 +36,10 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
id: uniqueId(),
|
||||
type: 'bookmark',
|
||||
cfi,
|
||||
href,
|
||||
text: truncatedText,
|
||||
note: '',
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
bookmarks.push(bookmark);
|
||||
const updatedConfig = updateBooknotes(bookKey, bookmarks);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useClickEvent } from '../hooks/useClickEvent';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
|
||||
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
@@ -15,57 +18,7 @@ import {
|
||||
handleClick,
|
||||
handleWheel,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export interface FoliateView extends HTMLElement {
|
||||
open: (book: BookDoc) => Promise<void>;
|
||||
close: () => void;
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goTo: (href: string) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
prev: (distance: number) => void;
|
||||
next: (distance: number) => void;
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||
clearSearch: () => void;
|
||||
select: (target: string | number | { fraction: number }) => void;
|
||||
deselect: () => void;
|
||||
history: {
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
back: () => void;
|
||||
forward: () => void;
|
||||
clear: () => void;
|
||||
};
|
||||
renderer: {
|
||||
scrolled?: boolean;
|
||||
viewSize: number;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
goTo?: (params: { index: number; anchor: number }) => void;
|
||||
setStyles?: (css: string) => void;
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
||||
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
||||
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
||||
// transform BookNote to foliate annotation
|
||||
const annotation = {
|
||||
value: note.cfi,
|
||||
...note,
|
||||
};
|
||||
return originalAddAnnotation(annotation, remove);
|
||||
};
|
||||
return originalView;
|
||||
};
|
||||
import Toast from '@/components/Toast';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
@@ -75,12 +28,18 @@ const FoliateViewer: React.FC<{
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey, setViewSettings } = useReaderStore();
|
||||
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setToastMessage(''), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toastMessage]);
|
||||
|
||||
useProgressSync(bookKey, setToastMessage);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
@@ -88,34 +47,7 @@ const FoliateViewer: React.FC<{
|
||||
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 { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
|
||||
const docLoadHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc loaded:', detail);
|
||||
@@ -157,69 +89,7 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
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 {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
|
||||
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
|
||||
useFoliateEvents(viewRef.current, {
|
||||
onLoad: docLoadHandler,
|
||||
onRelocate: progressRelocateHandler,
|
||||
@@ -288,11 +158,16 @@ const FoliateViewer: React.FC<{
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
/>
|
||||
{toastMessage && (
|
||||
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FoliateView } from './FoliateViewer';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
@@ -41,7 +41,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
renderer.setAttribute('margin', '0px');
|
||||
renderer.setAttribute('gap', '5%');
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
const popupTheme = { ...themeCode };
|
||||
const popupContainer = document.getElementById('popup-container');
|
||||
if (popupContainer) {
|
||||
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
|
||||
popupTheme.bg = backgroundColor;
|
||||
}
|
||||
renderer.setStyles?.(getStyles(viewSettings, popupTheme));
|
||||
};
|
||||
|
||||
const handleRender = (e: Event) => {
|
||||
@@ -57,7 +63,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
footnoteHandler.removeEventListener('render', handleRender);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
}, [view, themeCode]);
|
||||
|
||||
const docLinkHandler = async (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -38,11 +38,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
useBookShortcuts({ sideBarBookKey, bookKeys });
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const bookIds = ids || searchParams.get('ids') || '';
|
||||
const bookIds = ids || searchParams?.get('ids') || '';
|
||||
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
|
||||
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
|
||||
setBookKeys(initialBookKeys);
|
||||
@@ -60,16 +60,28 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const saveConfigAndCloseBook = (bookKey: string) => {
|
||||
console.log('Closing book', bookKey);
|
||||
getView(bookKey)?.close();
|
||||
getView(bookKey)?.remove();
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', handleCloseBooks);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleCloseBooks);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKeys]);
|
||||
|
||||
const saveBookConfig = (bookKey: string) => {
|
||||
const config = getConfig(bookKey);
|
||||
const { book } = getBookData(bookKey) || {};
|
||||
const { isPrimary } = getViewState(bookKey) || {};
|
||||
if (isPrimary && book && config) {
|
||||
saveConfig(envConfig, bookKey, config, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfigAndCloseBook = async (bookKey: string) => {
|
||||
console.log('Closing book', bookKey);
|
||||
getView(bookKey)?.close();
|
||||
getView(bookKey)?.remove();
|
||||
saveBookConfig(bookKey);
|
||||
clearViewState(bookKey);
|
||||
};
|
||||
|
||||
@@ -82,7 +94,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
bookKeys.forEach((key) => {
|
||||
saveConfigAndCloseBook(key);
|
||||
});
|
||||
saveSettingsAndGoToLibrary();
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleCloseBooksToLibrary = () => {
|
||||
handleCloseBooks();
|
||||
navigateToLibrary(router);
|
||||
};
|
||||
|
||||
const handleCloseBook = async (bookKey: string) => {
|
||||
@@ -116,7 +133,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
return (
|
||||
<div className='flex h-screen'>
|
||||
<SideBar onGoToLibrary={handleCloseBooks} />
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<Notebook />
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,12 @@ import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import Toast from '@/components/Toast';
|
||||
@@ -24,7 +26,6 @@ import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
import WikipediaPopup from './WikipediaPopup';
|
||||
import DeepLPopup from './DeepLPopup';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
|
||||
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { envConfig } = useEnv();
|
||||
@@ -33,6 +34,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
|
||||
const { isNotebookPinned, isNotebookVisible } = useNotebookStore();
|
||||
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
|
||||
|
||||
useNotesSync(bookKey);
|
||||
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
@@ -198,6 +202,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { booknotes = [] } = config;
|
||||
const annotations = booknotes.filter(
|
||||
(item) =>
|
||||
!item.deletedAt &&
|
||||
item.type === 'annotation' &&
|
||||
item.style &&
|
||||
CFI.compare(item.cfi, start) >= 0 &&
|
||||
@@ -217,17 +222,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
const { booknotes: annotations = [] } = config;
|
||||
if (selection) navigator.clipboard.writeText(selection.text);
|
||||
const { sectionHref: href } = progress;
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
const annotation: BookNote = {
|
||||
id: uniqueId(),
|
||||
type: 'excerpt',
|
||||
cfi,
|
||||
href,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const existingIndex = annotations.findIndex(
|
||||
@@ -250,7 +254,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
if (!selection || !selection.text) return;
|
||||
setHighlightOptionsVisible(true);
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const { sectionHref: href } = progress;
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
const style = settings.globalReadSettings.highlightStyle;
|
||||
@@ -259,12 +262,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
id: uniqueId(),
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
href,
|
||||
style,
|
||||
color,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const existingIndex = annotations.findIndex(
|
||||
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
|
||||
@@ -276,7 +279,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
annotations[existingIndex] = annotation;
|
||||
views.forEach((view) => view?.addAnnotation(annotation));
|
||||
} else {
|
||||
annotations.splice(existingIndex, 1);
|
||||
annotations[existingIndex]!.deletedAt = Date.now();
|
||||
setShowAnnotPopup(false);
|
||||
}
|
||||
} else {
|
||||
@@ -393,7 +396,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{toastMessage && <Toast message={toastMessage} />}
|
||||
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -72,9 +72,9 @@ const Notebook: React.FC = ({}) => {
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
note,
|
||||
href: selection.href || '',
|
||||
text: selection.text,
|
||||
created: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
annotations.push(annotation);
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
@@ -90,7 +90,7 @@ const Notebook: React.FC = ({}) => {
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const existingIndex = annotations.findIndex((item) => item.id === annotation.id);
|
||||
if (existingIndex === -1) return;
|
||||
annotation.modified = Date.now();
|
||||
annotation.updatedAt = Date.now();
|
||||
annotations[existingIndex] = annotation;
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
if (updatedConfig) {
|
||||
@@ -118,10 +118,10 @@ const Notebook: React.FC = ({}) => {
|
||||
const { booknotes: allNotes = [] } = config || {};
|
||||
const annotationNotes = allNotes
|
||||
.filter((note) => note.type === 'annotation' && note.note)
|
||||
.sort((a, b) => b.created - a.created);
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
const excerptNotes = allNotes
|
||||
.filter((note) => note.type === 'excerpt')
|
||||
.sort((a, b) => a.created - b.created);
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
return isNotebookVisible ? (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getStyles } from '@/utils/style';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
const cssRegex =
|
||||
/((?:\s*)([\w#.@*,:\-.:>+~\[\]\"=(),*\s]+)\s*{(?:[\s]*)((?:[A-Za-z\- \s]+[:]\s*['"0-9\w .,\/\()\-!#%]+;?)*)*\s*}(?:\s*))/gim;
|
||||
/((?:\s*)([\w#.@*,:\-.:>+~$$$$\"=(),*\s]+)\s*{(?:[\s]*)((?:[^\}]+[:][^\}]+;?)*)*\s*}(?:\s*))/gim;
|
||||
|
||||
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
@@ -15,6 +15,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [userStylesheet, setUserStylesheet] = useState(viewSettings.userStylesheet!);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -73,6 +74,16 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.disableClick = isDisableClick;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.disableClick = isDisableClick;
|
||||
setSettings(settings);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='w-full'>
|
||||
@@ -92,6 +103,23 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>Behavior</h2>
|
||||
<div className='card bg-base-100 border shadow'>
|
||||
<div className='divide-y'>
|
||||
<div className='config-item config-item-top config-item-bottom'>
|
||||
<span className='text-gray-700'>Disable Click-to-Flip</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isDisableClick}
|
||||
onChange={() => setIsDisableClick(!isDisableClick)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>Custom CSS</h2>
|
||||
<div className={`card bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import * as CFI from 'foliate-js/epubcfi.js';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { findParentPath } from '@/utils/toc';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { BookNote, BookNoteType } from '@/types/book';
|
||||
import BooknoteItem from './BooknoteItem';
|
||||
@@ -26,17 +26,14 @@ const BooknoteView: React.FC<{
|
||||
|
||||
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
|
||||
for (const booknote of booknotes) {
|
||||
const parentPath = findParentPath(toc, booknote.href);
|
||||
if (parentPath.length > 0) {
|
||||
const href = parentPath[0]!.href || '';
|
||||
const label = parentPath[0]!.label || '';
|
||||
const id = toc.findIndex((item) => item.href === href) || Infinity;
|
||||
booknote.href = href;
|
||||
if (!booknoteGroups[href]) {
|
||||
booknoteGroups[href] = { id, href, label, booknotes: [] };
|
||||
}
|
||||
booknoteGroups[href].booknotes.push(booknote);
|
||||
const tocItem = findTocItemBS(toc, booknote.cfi);
|
||||
const href = tocItem?.href || '';
|
||||
const label = tocItem?.label || '';
|
||||
const id = tocItem?.id || 0;
|
||||
if (!booknoteGroups[href]) {
|
||||
booknoteGroups[href] = { id, href, label, booknotes: [] };
|
||||
}
|
||||
booknoteGroups[href].booknotes.push(booknote);
|
||||
}
|
||||
|
||||
Object.values(booknoteGroups).forEach((group) => {
|
||||
|
||||
@@ -34,7 +34,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
const view = getView(bookKey)!;
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const searchConfig = config.searchConfig!;
|
||||
const searchConfig = config.searchConfig! as BookSearchConfig;
|
||||
|
||||
const queuedSearchTerm = useRef('');
|
||||
const isSearchPending = useRef(false);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
|
||||
export const useAutoHideScrollbar = () => {
|
||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
return { shouldAutoHideScrollbar, handleScrollbarAutoHide };
|
||||
};
|
||||
@@ -21,7 +21,7 @@ const useBooksManager = () => {
|
||||
if (shouldUpdateSearchParams) {
|
||||
const ids = bookKeys.map((key) => key.split('-')[0]!);
|
||||
if (ids) {
|
||||
navigateToReader(router, ids, searchParams.toString(), { scroll: false });
|
||||
navigateToReader(router, ids, searchParams?.toString() || '', { scroll: false });
|
||||
}
|
||||
setShouldUpdateSearchParams(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export const useClickEvent = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
if (viewSettings.disableClick!) {
|
||||
return;
|
||||
}
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
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 {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
|
||||
return {
|
||||
handleTurnPage,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
import { FoliateView } from '@/types/view';
|
||||
|
||||
type FoliateEventHandler = {
|
||||
onLoad?: (event: Event) => void;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
export const useNotesSync = (bookKey: string) => {
|
||||
const { user } = useAuth();
|
||||
const { syncedNotes, syncNotes, lastSyncedAtNotes } = useSync(bookKey);
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
|
||||
const config = getConfig(bookKey);
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || !user) return;
|
||||
syncNotes([], bookHash, 'pull');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!config || !user) return;
|
||||
const bookNotes = config.booknotes ?? [];
|
||||
const newNotes = bookNotes.filter(
|
||||
(note) => lastSyncedAtNotes < note.updatedAt || lastSyncedAtNotes < (note.deletedAt ?? 0),
|
||||
);
|
||||
newNotes.forEach((note) => {
|
||||
note.bookHash = bookHash;
|
||||
});
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastSyncTime.current;
|
||||
if (timeSinceLastSync > SYNC_NOTES_INTERVAL_SEC * 1000) {
|
||||
lastSyncTime.current = now;
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastSyncTime.current = Date.now();
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_NOTES_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncedNotes?.length && config) {
|
||||
const newNotes = syncedNotes.filter((note) => note.bookHash === bookHash);
|
||||
if (!newNotes.length) return;
|
||||
const oldNotes = config.booknotes ?? [];
|
||||
const mergedNotes = [
|
||||
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
|
||||
...newNotes,
|
||||
];
|
||||
setConfig(bookKey, { ...config, booknotes: mergedNotes });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedNotes]);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
export const useProgressSync = (
|
||||
bookKey: string,
|
||||
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
|
||||
) => {
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { syncedConfigs, syncConfigs } = useSync(bookKey);
|
||||
const { user } = useAuth();
|
||||
const view = getView(bookKey);
|
||||
const config = getConfig(bookKey);
|
||||
|
||||
const pushConfig = (bookKey: string, config: BookConfig | null) => {
|
||||
if (!config || !user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
const newConfig = { bookHash, ...config };
|
||||
const compressedConfig = JSON.parse(
|
||||
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
|
||||
);
|
||||
syncConfigs([compressedConfig], bookHash, 'push');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || !user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
syncConfigs([], bookHash, 'pull');
|
||||
return () => {
|
||||
pushConfig(bookKey, config);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastProgressSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!config || !user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastProgressSyncTime.current;
|
||||
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
|
||||
lastProgressSyncTime.current = now;
|
||||
pushConfig(bookKey, config);
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastProgressSyncTime.current = Date.now();
|
||||
pushConfig(bookKey, config);
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
// sync progress once when the book is opened
|
||||
const configSynced = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!configSynced.current && syncedConfigs?.length > 0) {
|
||||
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
|
||||
if (syncedConfig) {
|
||||
const newConfig = deserializeConfig(
|
||||
JSON.stringify(syncedConfig),
|
||||
settings.globalViewSettings,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
);
|
||||
setConfig(bookKey, { ...config, ...newConfig });
|
||||
configSynced.current = true;
|
||||
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config?.progress?.[1] ?? 0) > 0) {
|
||||
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
|
||||
const configFraction = config!.progress![0] / config!.progress![1];
|
||||
if (syncedFraction > configFraction) {
|
||||
view?.goToFraction(syncedFraction);
|
||||
setToastMessage?.('Progress synced');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedConfigs]);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
|
||||
interface MenuItemProps {
|
||||
label: string;
|
||||
labelClass?: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
noIcon?: boolean;
|
||||
@@ -13,6 +14,7 @@ interface MenuItemProps {
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
label,
|
||||
labelClass,
|
||||
shortcut,
|
||||
disabled,
|
||||
noIcon = false,
|
||||
@@ -31,7 +33,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
{!noIcon && <span style={{ minWidth: '20px' }}>{icon}</span>}
|
||||
<span className='ml-2 max-w-32 truncate'>{label}</span>
|
||||
<span className={clsx('ml-2 max-w-32 truncate', labelClass)}>{label}</span>
|
||||
</div>
|
||||
{shortcut && <span className='text-neutral-content text-sm'>{shortcut}</span>}
|
||||
</button>
|
||||
|
||||
@@ -66,6 +66,7 @@ const Popup = ({
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
id='popup-container'
|
||||
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => (
|
||||
<CSPostHogProvider>
|
||||
<EnvProvider>
|
||||
<AuthProvider>
|
||||
<SyncProvider>{children}</SyncProvider>
|
||||
</AuthProvider>
|
||||
</EnvProvider>
|
||||
</CSPostHogProvider>
|
||||
);
|
||||
|
||||
export default Providers;
|
||||
@@ -1,8 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
const Toast: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div className='toast toast-center toast-middle'>
|
||||
<div className='alert flex items-center justify-center border-0 bg-gray-600 text-white'>
|
||||
const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({
|
||||
message,
|
||||
toastClass,
|
||||
alertClass,
|
||||
}) => (
|
||||
<div className={clsx('toast toast-center toast-middle', toastClass)}>
|
||||
<div className={clsx('alert flex items-center justify-center border-0', alertClass)}>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { tauriHandleMinimize, tauriHandleToggleMaximize, tauriHandleClose } from '@/utils/window';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
interface WindowButtonsProps {
|
||||
className?: string;
|
||||
@@ -66,6 +67,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
const headerElement = headerRef?.current;
|
||||
headerElement?.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
|
||||
@@ -1,22 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useState, useContext, ReactNode } from 'react';
|
||||
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
login: (token: string) => void;
|
||||
user: User | null;
|
||||
login: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [user, setUser] = useState<User | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const userJson = localStorage.getItem('user');
|
||||
return userJson ? JSON.parse(userJson) : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const login = (newToken: string) => setToken(newToken);
|
||||
const logout = () => setToken(null);
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
const { access_token, user } = session;
|
||||
setToken(access_token);
|
||||
setUser(user);
|
||||
localStorage.setItem('token', access_token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={{ token, login, logout }}>{children}</AuthContext.Provider>;
|
||||
console.log('Fetching session');
|
||||
fetchSession();
|
||||
}, [token]);
|
||||
|
||||
const login = (newToken: string, newUser: User) => {
|
||||
console.log('Logging in');
|
||||
setToken(newToken);
|
||||
setUser(newUser);
|
||||
localStorage.setItem('token', newToken);
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
console.log('Logging out');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
supabase.auth.signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, user, login, logout }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { SyncClient } from '@/libs/sync';
|
||||
|
||||
const syncClient = new SyncClient();
|
||||
|
||||
interface SyncContextType {
|
||||
syncClient: SyncClient;
|
||||
}
|
||||
|
||||
const SyncContext = createContext<SyncContextType>({ syncClient });
|
||||
|
||||
export const SyncProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <SyncContext.Provider value={{ syncClient }}>{children}</SyncContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSyncContext = () => useContext(SyncContext);
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"library": ["https://cdn.readest.com/books/four-great-classics.epub"]
|
||||
"library": [
|
||||
"https://cdn.readest.com/books/four-great-classics.epub",
|
||||
"https://cdn.readest.com/books/journey-to-the-west.epub"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
interface UseAuthCallbackOptions {
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
login: (accessToken: string, user: User) => void;
|
||||
navigate: (path: string) => void;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
export function handleAuthCallback({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
login,
|
||||
navigate,
|
||||
next = '/',
|
||||
}: UseAuthCallbackOptions) {
|
||||
async function finalizeSession() {
|
||||
if (!accessToken || !refreshToken) {
|
||||
console.error('No access token or refresh token');
|
||||
navigate('/auth/error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error setting session:', error);
|
||||
navigate('/auth/error');
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
login(accessToken, user);
|
||||
navigate(next);
|
||||
} else {
|
||||
console.error('Error fetching user data');
|
||||
navigate('/auth/error');
|
||||
}
|
||||
}
|
||||
|
||||
finalizeSession();
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSyncContext } from '@/context/SyncContext';
|
||||
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { transformBookConfigFromDB } from '@/utils/transform';
|
||||
import { transformBookNoteFromDB } from '@/utils/transform';
|
||||
import { transformBookFromDB } from '@/utils/transform';
|
||||
import { DBBook, DBBookConfig, DBBookNote } from '@/types/records';
|
||||
import { Book, BookConfig, BookDataRecord, BookNote } from '@/types/book';
|
||||
|
||||
const transformsFromDB = {
|
||||
books: transformBookFromDB,
|
||||
notes: transformBookNoteFromDB,
|
||||
configs: transformBookConfigFromDB,
|
||||
};
|
||||
|
||||
const computeMaxTimestamp = (records: BookDataRecord[]): number => {
|
||||
let maxTime = 0;
|
||||
for (const rec of records) {
|
||||
if (rec.updated_at) {
|
||||
const updatedTime = new Date(rec.updated_at).getTime();
|
||||
maxTime = Math.max(maxTime, updatedTime);
|
||||
}
|
||||
if (rec.deleted_at) {
|
||||
const deletedTime = new Date(rec.deleted_at).getTime();
|
||||
maxTime = Math.max(maxTime, deletedTime);
|
||||
}
|
||||
}
|
||||
return maxTime;
|
||||
};
|
||||
|
||||
export function useSync(bookKey?: string) {
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const config = bookKey ? getConfig(bookKey) : null;
|
||||
|
||||
const [syncingBooks, setSyncingBooks] = useState(false);
|
||||
const [syncingConfigs, setSyncingConfigs] = useState(false);
|
||||
const [syncingNotes, setSyncingNotes] = useState(false);
|
||||
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(
|
||||
settings.lastSyncedAtBooks ?? 0,
|
||||
);
|
||||
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(
|
||||
config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0,
|
||||
);
|
||||
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(
|
||||
config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0,
|
||||
);
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncResult>({
|
||||
books: [],
|
||||
configs: [],
|
||||
notes: [],
|
||||
});
|
||||
const [syncedBooks, setSyncedBooks] = useState<Book[]>([]);
|
||||
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[]>([]);
|
||||
const [syncedNotes, setSyncedNotes] = useState<BookNote[]>([]);
|
||||
|
||||
const { syncClient } = useSyncContext();
|
||||
|
||||
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
|
||||
// and update the lastSyncedAt for that book in the book config
|
||||
const pullChanges = async (
|
||||
type: SyncType,
|
||||
since: number,
|
||||
setLastSyncedAt: React.Dispatch<React.SetStateAction<number>>,
|
||||
setSyncing: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
bookId?: string,
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setSyncError(null);
|
||||
|
||||
try {
|
||||
const result = await syncClient.pullChanges(since, type, bookId);
|
||||
const records = result[type];
|
||||
if (!records.length) return;
|
||||
const maxTime = computeMaxTimestamp(result[type]);
|
||||
setLastSyncedAt(maxTime);
|
||||
setSyncResult(result);
|
||||
switch (type) {
|
||||
case 'books':
|
||||
settings.lastSyncedAtBooks = maxTime;
|
||||
break;
|
||||
case 'configs':
|
||||
if (!bookId) {
|
||||
settings.lastSyncedAtConfigs = maxTime;
|
||||
} else if (bookKey && config) {
|
||||
config.lastSyncedAtConfig = maxTime;
|
||||
setConfig(bookKey, config);
|
||||
}
|
||||
break;
|
||||
case 'notes':
|
||||
if (!bookId) {
|
||||
settings.lastSyncedAtNotes = maxTime;
|
||||
} else if (bookKey && config) {
|
||||
config.lastSyncedAtNotes = maxTime;
|
||||
setConfig(bookKey, config);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
setSyncError(err.message || `Error pulling ${type}`);
|
||||
} else {
|
||||
setSyncError(`Error pulling ${type}`);
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pushChanges = async (payload: SyncData) => {
|
||||
setSyncing(true);
|
||||
setSyncError(null);
|
||||
|
||||
try {
|
||||
const result = await syncClient.pushChanges(payload);
|
||||
setSyncResult(result);
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
setSyncError(err.message || 'Error pushing changes');
|
||||
} else {
|
||||
setSyncError('Error pushing changes');
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncBooks = async (books?: Book[], op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && books?.length) {
|
||||
await pushChanges({ books });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
|
||||
}
|
||||
};
|
||||
|
||||
const syncConfigs = async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
|
||||
await pushChanges({ configs: bookConfigs });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges(
|
||||
'configs',
|
||||
lastSyncedAtConfigs,
|
||||
setLastSyncedAtConfigs,
|
||||
setSyncingConfigs,
|
||||
bookId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNotes = async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && bookNotes?.length) {
|
||||
await pushChanges({ notes: bookNotes });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges('notes', lastSyncedAtNotes, setLastSyncedAtNotes, setSyncingNotes, bookId);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncing && syncResult) {
|
||||
const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult;
|
||||
const books = dbBooks.map((dbBook) => transformsFromDB['books'](dbBook as unknown as DBBook));
|
||||
const configs = dbBookConfigs.map((dbBookConfig) =>
|
||||
transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig),
|
||||
);
|
||||
const notes = dbBookNotes.map((dbBookNote) =>
|
||||
transformsFromDB['notes'](dbBookNote as unknown as DBBookNote),
|
||||
);
|
||||
if (books.length) setSyncedBooks(books);
|
||||
if (configs.length) setSyncedConfigs(configs);
|
||||
if (notes.length) setSyncedNotes(notes);
|
||||
}
|
||||
}, [syncResult, syncing]);
|
||||
|
||||
return {
|
||||
syncing: syncingBooks || syncingConfigs || syncingNotes,
|
||||
syncError,
|
||||
syncResult,
|
||||
syncedBooks,
|
||||
syncedConfigs,
|
||||
syncedNotes,
|
||||
lastSyncedAtBooks,
|
||||
lastSyncedAtNotes,
|
||||
lastSyncedAtConfigs,
|
||||
pullChanges,
|
||||
pushChanges,
|
||||
syncBooks,
|
||||
syncConfigs,
|
||||
syncNotes,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookFormat } from '@/types/book';
|
||||
import * as epubcfi from 'foliate-js/epubcfi.js';
|
||||
|
||||
// A groupBy polyfill for foliate-js
|
||||
Object.groupBy ??= (iterable, callbackfn) => {
|
||||
@@ -30,15 +31,24 @@ Map.groupBy ??= (iterable, callbackfn) => {
|
||||
return map;
|
||||
};
|
||||
|
||||
export const CFI = epubcfi;
|
||||
|
||||
export type DocumentFile = File;
|
||||
|
||||
export interface TOCItem {
|
||||
id: number;
|
||||
label: string;
|
||||
href: string;
|
||||
cfi?: string;
|
||||
subitems?: TOCItem[];
|
||||
}
|
||||
|
||||
export interface SectionItem {
|
||||
id: string;
|
||||
cfi: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BookDoc {
|
||||
metadata: {
|
||||
title: string;
|
||||
@@ -48,6 +58,8 @@ export interface BookDoc {
|
||||
publisher?: string;
|
||||
};
|
||||
toc: Array<TOCItem>;
|
||||
sections: Array<SectionItem>;
|
||||
splitTOCHref(href: string): Array<string | number>;
|
||||
getCover(): Promise<Blob | null>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
|
||||
import { READEST_WEB_BASE_URL } from '@/services/constants';
|
||||
|
||||
const BASE_URL = process.env['NODE_ENV'] === 'production' ? READEST_WEB_BASE_URL : '';
|
||||
|
||||
export type SyncType = 'books' | 'configs' | 'notes';
|
||||
export type SyncOp = 'push' | 'pull' | 'both';
|
||||
|
||||
interface BookRecord extends BookDataRecord, Book {}
|
||||
interface BookConfigRecord extends BookDataRecord, BookConfig {}
|
||||
interface BookNoteRecord extends BookDataRecord, BookNote {}
|
||||
|
||||
export interface SyncResult {
|
||||
books: BookRecord[];
|
||||
notes: BookNoteRecord[];
|
||||
configs: BookConfigRecord[];
|
||||
}
|
||||
|
||||
export interface SyncData {
|
||||
books?: Partial<BookRecord>[];
|
||||
notes?: Partial<BookNoteRecord>[];
|
||||
configs?: Partial<BookConfigRecord>[];
|
||||
}
|
||||
|
||||
export class SyncClient {
|
||||
/**
|
||||
* Pull incremental changes since a given timestamp (in ms).
|
||||
* Returns updated or deleted records since that time.
|
||||
*/
|
||||
async pullChanges(since: number, type?: SyncType, book?: string): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const url = `${BASE_URL}/api/sync?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(`Failed to pull changes: ${error.error || res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local changes to the server.
|
||||
* Uses last-writer-wins logic as implemented on the server side.
|
||||
*/
|
||||
async pushChanges(payload: SyncData): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(`Failed to push changes: ${error.error || res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string | null> {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.access_token ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AppProps } from 'next/app';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
import '../styles/globals.css';
|
||||
import '../styles/fonts.css';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<Providers>
|
||||
<Component {...pageProps} />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
@@ -0,0 +1,281 @@
|
||||
import Cors from 'cors';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PostgrestError } from '@supabase/supabase-js';
|
||||
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { BookDataRecord } from '@/types/book';
|
||||
import { transformBookConfigToDB } from '@/utils/transform';
|
||||
import { transformBookNoteToDB } from '@/utils/transform';
|
||||
import { transformBookToDB } from '@/utils/transform';
|
||||
import { SyncData, SyncResult, SyncType } from '@/libs/sync';
|
||||
|
||||
const transformsToDB = {
|
||||
books: transformBookToDB,
|
||||
book_notes: transformBookNoteToDB,
|
||||
book_configs: transformBookConfigToDB,
|
||||
};
|
||||
|
||||
const DBSyncTypeMap = {
|
||||
books: 'books',
|
||||
book_notes: 'notes',
|
||||
book_configs: 'configs',
|
||||
};
|
||||
|
||||
type TableName = keyof typeof transformsToDB;
|
||||
|
||||
type DBError = { table: TableName; error: PostgrestError };
|
||||
|
||||
const getUserAndToken = async (req: NextRequest) => {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const sinceParam = searchParams.get('since');
|
||||
const typeParam = searchParams.get('type') as SyncType | undefined;
|
||||
const bookParam = searchParams.get('book');
|
||||
|
||||
if (!sinceParam) {
|
||||
return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const since = new Date(Number(sinceParam));
|
||||
if (isNaN(since.getTime())) {
|
||||
return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sinceIso = since.toISOString();
|
||||
|
||||
try {
|
||||
const results: SyncResult = { books: [], configs: [], notes: [] };
|
||||
const errors: Record<TableName, DBError | null> = {
|
||||
books: null,
|
||||
book_notes: null,
|
||||
book_configs: null,
|
||||
};
|
||||
|
||||
const queryTables = async (table: TableName) => {
|
||||
let query = supabase.from(table).select('*').eq('user_id', user.id);
|
||||
if (bookParam) {
|
||||
query.eq('book_hash', bookParam);
|
||||
}
|
||||
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
|
||||
console.log('Querying table:', table, 'since:', sinceIso);
|
||||
const { data, error } = await query;
|
||||
if (error) throw { table, error } as DBError;
|
||||
results[DBSyncTypeMap[table] as SyncType] = data || [];
|
||||
};
|
||||
|
||||
if (!typeParam || typeParam === 'books') {
|
||||
await queryTables('books').catch((err) => (errors['books'] = err));
|
||||
}
|
||||
if (!typeParam || typeParam === 'configs') {
|
||||
await queryTables('book_configs').catch((err) => (errors['book_configs'] = err));
|
||||
}
|
||||
if (!typeParam || typeParam === 'notes') {
|
||||
await queryTables('book_notes').catch((err) => (errors['book_notes'] = err));
|
||||
}
|
||||
|
||||
const dbErrors = Object.values(errors).filter((err) => err !== null);
|
||||
if (dbErrors.length > 0) {
|
||||
console.error('Errors occurred:', dbErrors);
|
||||
const errorMsg = dbErrors
|
||||
.map((err) => `${err.table}: ${err.error.message || 'Unknown error'}`)
|
||||
.join('; ');
|
||||
return NextResponse.json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(results, { status: 200 });
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
const body = await req.json();
|
||||
const { books = [], configs = [], notes = [] } = body as SyncData;
|
||||
|
||||
const upsertRecords = async (
|
||||
table: TableName,
|
||||
primaryKeys: (keyof BookDataRecord)[],
|
||||
records: BookDataRecord[],
|
||||
) => {
|
||||
const authoritativeRecords: BookDataRecord[] = [];
|
||||
|
||||
for (const rec of records) {
|
||||
const dbRec = transformsToDB[table](rec, user.id);
|
||||
rec.user_id = user.id;
|
||||
rec.book_hash = dbRec.book_hash;
|
||||
const matchConditions: Record<string, string | number> = { user_id: user.id };
|
||||
for (const pk of primaryKeys) {
|
||||
matchConditions[pk] = rec[pk]!;
|
||||
}
|
||||
|
||||
const { data: serverData, error: fetchError } = await supabase
|
||||
.from(table)
|
||||
.select()
|
||||
.match(matchConditions)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
return { error: fetchError.message };
|
||||
}
|
||||
|
||||
if (!serverData) {
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from(table)
|
||||
.insert(dbRec)
|
||||
.select()
|
||||
.single();
|
||||
console.log('Inserted record:', inserted);
|
||||
if (insertError) return { error: insertError.message };
|
||||
authoritativeRecords.push(inserted);
|
||||
} else {
|
||||
const clientUpdatedAt = dbRec.updated_at ? new Date(dbRec.updated_at).getTime() : 0;
|
||||
const serverUpdatedAt = serverData.updated_at
|
||||
? new Date(serverData.updated_at).getTime()
|
||||
: 0;
|
||||
const clientDeletedAt = dbRec.deleted_at ? new Date(dbRec.deleted_at).getTime() : 0;
|
||||
const serverDeletedAt = serverData.deleted_at
|
||||
? new Date(serverData.deleted_at).getTime()
|
||||
: 0;
|
||||
const clientIsNewer =
|
||||
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
|
||||
|
||||
if (clientIsNewer) {
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from(table)
|
||||
.update(dbRec)
|
||||
.match(matchConditions)
|
||||
.select()
|
||||
.single();
|
||||
console.log('Updated record:', updated);
|
||||
if (updateError) return { error: updateError.message };
|
||||
authoritativeRecords.push(updated);
|
||||
} else {
|
||||
authoritativeRecords.push(serverData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data: authoritativeRecords };
|
||||
};
|
||||
|
||||
try {
|
||||
const [booksResult, configsResult, notesResult] = await Promise.all([
|
||||
upsertRecords('books', ['book_hash'], books as BookDataRecord[]),
|
||||
upsertRecords('book_configs', ['book_hash'], configs as BookDataRecord[]),
|
||||
upsertRecords('book_notes', ['book_hash', 'id'], notes as BookDataRecord[]),
|
||||
]);
|
||||
|
||||
if (booksResult?.error) throw new Error(booksResult.error);
|
||||
if (configsResult?.error) throw new Error(configsResult.error);
|
||||
if (notesResult?.error) throw new Error(notesResult.error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
books: booksResult?.data || [],
|
||||
configs: configsResult?.data || [],
|
||||
notes: notesResult?.data || [],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to wait for a middleware to execute before continuing
|
||||
// And to throw an error when an error happens in a middleware
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: Function) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(req, res, (result: unknown) => {
|
||||
if (result instanceof Error) {
|
||||
return reject(result);
|
||||
}
|
||||
|
||||
return resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const cors = Cors({
|
||||
methods: ['POST', 'GET', 'HEAD'],
|
||||
});
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (!req.url) {
|
||||
return res.status(400).json({ error: 'Invalid request URL' });
|
||||
}
|
||||
|
||||
const protocol = process.env['PROTOCOL'] || 'http';
|
||||
const host = process.env['HOST'] || 'localhost:3000';
|
||||
const url = new URL(req.url, `${protocol}://${host}`);
|
||||
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const nextReq = new NextRequest(url.toString(), {
|
||||
headers: new Headers(req.headers as Record<string, string>),
|
||||
method: 'GET',
|
||||
});
|
||||
response = await GET(nextReq);
|
||||
} else if (req.method === 'POST') {
|
||||
const nextReq = new NextRequest(url.toString(), {
|
||||
headers: new Headers(req.headers as Record<string, string>),
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req.body), // Ensure the body is a string
|
||||
});
|
||||
response = await POST(nextReq);
|
||||
} else {
|
||||
res.setHeader('Allow', ['GET', 'POST']);
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('Error processing request:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
import Reader from '@/app/reader/components/Reader';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const ids = router.query['ids'] as string;
|
||||
return (
|
||||
<CSPostHogProvider>
|
||||
<EnvProvider>
|
||||
<AuthProvider>
|
||||
<SyncProvider>
|
||||
<Reader ids={ids} />
|
||||
</SyncProvider>
|
||||
</AuthProvider>
|
||||
</EnvProvider>
|
||||
</CSPostHogProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { AppPlatform, AppService, ToastType } from '@/types/system';
|
||||
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { FileSystem, BaseDir } from '@/types/system';
|
||||
import { Book, BookConfig, BookContent, BookFormat, ViewSettings } from '@/types/book';
|
||||
import { Book, BookConfig, BookContent, BookFormat } from '@/types/book';
|
||||
import {
|
||||
getDir,
|
||||
getFilename,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
} from './constants';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
localBooksDir: string = '';
|
||||
@@ -70,6 +71,10 @@ export abstract class BaseAppService implements AppService {
|
||||
settings = {
|
||||
version: SYSTEM_SETTINGS_VERSION,
|
||||
localBooksDir: await this.getInitBooksDir(),
|
||||
lastSyncedAtBooks: 0,
|
||||
lastSyncedAtConfigs: 0,
|
||||
lastSyncedAtNotes: 0,
|
||||
keepLogin: false,
|
||||
globalReadSettings: DEFAULT_READSETTINGS,
|
||||
globalViewSettings: {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
@@ -126,10 +131,10 @@ export abstract class BaseAppService implements AppService {
|
||||
const hash = await partialMD5(fileobj);
|
||||
const existingBook = books.filter((b) => b.hash === hash)[0];
|
||||
if (existingBook) {
|
||||
if (existingBook.isRemoved) {
|
||||
delete existingBook.isRemoved;
|
||||
if (existingBook.deletedAt) {
|
||||
delete existingBook.deletedAt;
|
||||
}
|
||||
existingBook.lastUpdated = Date.now();
|
||||
existingBook.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
const book: Book = {
|
||||
@@ -137,7 +142,8 @@ export abstract class BaseAppService implements AppService {
|
||||
format,
|
||||
title: formatTitle(loadedBook.metadata.title),
|
||||
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
|
||||
lastUpdated: Date.now(),
|
||||
createdAt: existingBook ? existingBook.createdAt : Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
||||
await this.fs.createDir(getDir(book), 'Books');
|
||||
@@ -206,33 +212,22 @@ export abstract class BaseAppService implements AppService {
|
||||
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
|
||||
try {
|
||||
const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text');
|
||||
const config = JSON.parse(str as string) as BookConfig;
|
||||
const { globalViewSettings } = settings;
|
||||
const { viewSettings } = config;
|
||||
config.viewSettings = { ...globalViewSettings, ...viewSettings };
|
||||
config.searchConfig ??= DEFAULT_BOOK_SEARCH_CONFIG;
|
||||
return config;
|
||||
return deserializeConfig(str as string, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
} catch {
|
||||
return INIT_BOOK_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
|
||||
let serializedConfig: string;
|
||||
if (settings) {
|
||||
config = JSON.parse(JSON.stringify(config));
|
||||
const globalViewSettings = settings.globalViewSettings as ViewSettings;
|
||||
const viewSettings = config.viewSettings as Partial<ViewSettings>;
|
||||
config.viewSettings = Object.entries(viewSettings).reduce(
|
||||
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
|
||||
if (globalViewSettings[key as keyof ViewSettings] !== value) {
|
||||
acc[key as keyof ViewSettings] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<Record<keyof ViewSettings, unknown>>,
|
||||
) as Partial<ViewSettings>;
|
||||
const { globalViewSettings } = settings;
|
||||
serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
} else {
|
||||
serializedConfig = JSON.stringify(config);
|
||||
}
|
||||
await this.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config));
|
||||
await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
|
||||
}
|
||||
|
||||
async loadLibraryBooks(): Promise<Book[]> {
|
||||
@@ -255,6 +250,7 @@ export abstract class BaseAppService implements AppService {
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
book.updatedAt ??= book.lastUpdated || Date.now();
|
||||
return book;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
marginPx: 44,
|
||||
gapPercent: 5,
|
||||
scrolled: false,
|
||||
disableClick: false,
|
||||
maxColumnCount: 2,
|
||||
maxInlineSize: 720,
|
||||
maxBlockSize: 1440,
|
||||
@@ -83,3 +84,8 @@ export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
|
||||
export const BOOK_IDS_SEPARATOR = '+';
|
||||
|
||||
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
|
||||
|
||||
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
|
||||
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 60;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 60;
|
||||
|
||||
@@ -17,6 +17,7 @@ interface BookData {
|
||||
interface BookDataState {
|
||||
booksData: { [id: string]: BookData };
|
||||
getConfig: (key: string | null) => BookConfig | null;
|
||||
setConfig: (key: string, config: BookConfig) => void;
|
||||
saveConfig: (
|
||||
envConfig: EnvConfigType,
|
||||
bookKey: string,
|
||||
@@ -38,6 +39,20 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
const id = key.split('-')[0]!;
|
||||
return get().booksData[id]?.config || null;
|
||||
},
|
||||
setConfig: (key: string, config: BookConfig) => {
|
||||
set((state: BookDataState) => {
|
||||
const id = key.split('-')[0]!;
|
||||
return {
|
||||
booksData: {
|
||||
...state.booksData,
|
||||
[id]: {
|
||||
...state.booksData[id]!,
|
||||
config,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
saveConfig: async (
|
||||
envConfig: EnvConfigType,
|
||||
bookKey: string,
|
||||
@@ -49,10 +64,10 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
const bookIndex = library.findIndex((b) => b.hash === bookKey.split('-')[0]);
|
||||
if (bookIndex == -1) return;
|
||||
const book = library.splice(bookIndex, 1)[0]!;
|
||||
book.lastUpdated = Date.now();
|
||||
book.updatedAt = Date.now();
|
||||
library.unshift(book);
|
||||
setLibrary(library);
|
||||
config.lastUpdated = Date.now();
|
||||
config.updatedAt = Date.now();
|
||||
appService.saveBookConfig(book, config, settings);
|
||||
appService.saveLibraryBooks(library);
|
||||
},
|
||||
@@ -67,7 +82,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
);
|
||||
updatedConfig = {
|
||||
...book.config,
|
||||
lastUpdated: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
booknotes: dedupedBooknotes,
|
||||
};
|
||||
return {
|
||||
@@ -77,7 +92,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
...book,
|
||||
config: {
|
||||
...book.config,
|
||||
lastUpdated: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
booknotes: dedupedBooknotes,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,9 +2,9 @@ import { create } from 'zustand';
|
||||
|
||||
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import { updateTocID } from '@/utils/toc';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { BookDoc, DocumentLoader, SectionItem, TOCItem } from '@/libs/document';
|
||||
import { updateTocCFI, updateTocID } from '@/utils/toc';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useBookDataStore } from './bookDataStore';
|
||||
import { useLibraryStore } from './libraryStore';
|
||||
@@ -114,8 +114,13 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
console.log('Loading book', key);
|
||||
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
|
||||
const bookDoc = loadedBookDoc as BookDoc;
|
||||
if (bookDoc.toc) {
|
||||
if (bookDoc.toc?.length > 0 && bookDoc.sections?.length > 0) {
|
||||
updateTocID(bookDoc.toc);
|
||||
const sections = bookDoc.sections.reduce((map: Record<string, SectionItem>, section) => {
|
||||
map[section.id] = section;
|
||||
return map;
|
||||
}, {});
|
||||
updateTocCFI(bookDoc, bookDoc.toc, sections);
|
||||
}
|
||||
useBookDataStore.setState((state) => ({
|
||||
booksData: {
|
||||
@@ -177,7 +182,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
...bookData,
|
||||
config: {
|
||||
...bookData.config,
|
||||
lastUpdated: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
viewSettings,
|
||||
},
|
||||
},
|
||||
@@ -211,7 +216,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const oldConfig = bookData.config;
|
||||
const newConfig = {
|
||||
...bookData.config,
|
||||
lastUpdated: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
progress: [pageinfo.current, pageinfo.total] as [number, number],
|
||||
location,
|
||||
};
|
||||
|
||||
@@ -12,9 +12,13 @@ export interface Book {
|
||||
author: string;
|
||||
group?: string;
|
||||
tags?: string[];
|
||||
lastUpdated: number;
|
||||
isRemoved?: boolean;
|
||||
coverImageUrl?: string | null;
|
||||
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
deletedAt?: number;
|
||||
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
@@ -24,22 +28,25 @@ export interface PageInfo {
|
||||
}
|
||||
|
||||
export interface BookNote {
|
||||
bookHash?: string;
|
||||
id: string;
|
||||
type: BookNoteType;
|
||||
cfi: string;
|
||||
href: string;
|
||||
text?: string;
|
||||
style?: HighlightStyle;
|
||||
color?: HighlightColor;
|
||||
note: string;
|
||||
created: number;
|
||||
modified?: number;
|
||||
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
deletedAt?: number;
|
||||
}
|
||||
|
||||
export interface BookLayout {
|
||||
marginPx: number;
|
||||
gapPercent: number;
|
||||
scrolled: boolean;
|
||||
disableClick: boolean;
|
||||
maxColumnCount: number;
|
||||
maxInlineSize: number;
|
||||
maxBlockSize: number;
|
||||
@@ -107,21 +114,32 @@ export interface BookSearchResult {
|
||||
}
|
||||
|
||||
export interface BookConfig {
|
||||
lastUpdated: number;
|
||||
bookHash?: string;
|
||||
progress?: [number, number];
|
||||
location?: string;
|
||||
|
||||
booknotes?: BookNote[];
|
||||
removedNotesTimestamps?: Record<string, number>;
|
||||
|
||||
searchConfig?: BookSearchConfig;
|
||||
searchConfig?: Partial<BookSearchConfig>;
|
||||
viewSettings?: Partial<ViewSettings>;
|
||||
|
||||
lastSyncedAtConfig?: number;
|
||||
lastSyncedAtNotes?: number;
|
||||
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface BookDataRecord {
|
||||
id: string;
|
||||
book_hash: string;
|
||||
user_id: string;
|
||||
updated_at: number | null;
|
||||
deleted_at: number | null;
|
||||
}
|
||||
|
||||
export interface BooksGroup {
|
||||
name: string;
|
||||
books: Book[];
|
||||
lastUpdated: number;
|
||||
|
||||
updatedAt: number;
|
||||
}
|
||||
export interface BookContent {
|
||||
book: Book;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface DBBook {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
format: string;
|
||||
title: string;
|
||||
author: string;
|
||||
group?: string;
|
||||
tags?: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DBBookConfig {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
location?: string;
|
||||
progress?: string;
|
||||
search_config?: string;
|
||||
view_settings?: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DBBookNote {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
id: string;
|
||||
type: string;
|
||||
cfi: string;
|
||||
text?: string;
|
||||
style?: string;
|
||||
color?: string;
|
||||
note: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export interface ReadSettings {
|
||||
export interface SystemSettings {
|
||||
version: number;
|
||||
localBooksDir: string;
|
||||
keepLogin: boolean;
|
||||
|
||||
lastSyncedAtBooks: number;
|
||||
lastSyncedAtConfigs: number;
|
||||
lastSyncedAtNotes: number;
|
||||
|
||||
globalReadSettings: ReadSettings;
|
||||
globalViewSettings: ViewSettings;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
|
||||
export interface FoliateView extends HTMLElement {
|
||||
open: (book: BookDoc) => Promise<void>;
|
||||
close: () => void;
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goTo: (href: string) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
prev: (distance: number) => void;
|
||||
next: (distance: number) => void;
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||
clearSearch: () => void;
|
||||
select: (target: string | number | { fraction: number }) => void;
|
||||
deselect: () => void;
|
||||
history: {
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
back: () => void;
|
||||
forward: () => void;
|
||||
clear: () => void;
|
||||
};
|
||||
renderer: {
|
||||
scrolled?: boolean;
|
||||
viewSize: number;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
goTo?: (params: { index: number; anchor: number }) => void;
|
||||
setStyles?: (css: string) => void;
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
||||
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
||||
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
||||
// transform BookNote to foliate annotation
|
||||
const annotation = {
|
||||
value: note.cfi,
|
||||
...note,
|
||||
};
|
||||
return originalAddAnnotation(annotation, remove);
|
||||
};
|
||||
return originalView;
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export const getBaseFilename = (filename: string) => {
|
||||
return baseName;
|
||||
};
|
||||
export const INIT_BOOK_CONFIG: BookConfig = {
|
||||
lastUpdated: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
interface LanguageMap {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BookConfig, BookSearchConfig, ViewSettings } from '@/types/book';
|
||||
|
||||
export const serializeConfig = (
|
||||
config: BookConfig,
|
||||
globalViewSettings: ViewSettings,
|
||||
defaultSearchConfig: BookSearchConfig,
|
||||
): string => {
|
||||
config = JSON.parse(JSON.stringify(config));
|
||||
const viewSettings = config.viewSettings as Partial<ViewSettings>;
|
||||
const searchConfig = config.searchConfig as Partial<BookSearchConfig>;
|
||||
config.viewSettings = Object.entries(viewSettings).reduce(
|
||||
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
|
||||
if (globalViewSettings[key as keyof ViewSettings] !== value) {
|
||||
acc[key as keyof ViewSettings] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<Record<keyof ViewSettings, unknown>>,
|
||||
) as Partial<ViewSettings>;
|
||||
config.searchConfig = Object.entries(searchConfig).reduce(
|
||||
(acc: Partial<Record<keyof BookSearchConfig, unknown>>, [key, value]) => {
|
||||
if (defaultSearchConfig[key as keyof BookSearchConfig] !== value) {
|
||||
acc[key as keyof BookSearchConfig] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<BookSearchConfig>,
|
||||
) as Partial<BookSearchConfig>;
|
||||
|
||||
return JSON.stringify(config);
|
||||
};
|
||||
|
||||
export const deserializeConfig = (
|
||||
str: string,
|
||||
globalViewSettings: ViewSettings,
|
||||
defaultSearchConfig: BookSearchConfig,
|
||||
): BookConfig => {
|
||||
const config = JSON.parse(str) as BookConfig;
|
||||
const { viewSettings, searchConfig } = config;
|
||||
config.viewSettings = { ...globalViewSettings, ...viewSettings };
|
||||
config.searchConfig = { ...defaultSearchConfig, ...searchConfig };
|
||||
config.updatedAt ??= Date.now();
|
||||
return config;
|
||||
};
|
||||
|
||||
export const compressConfig = (
|
||||
config: BookConfig,
|
||||
globalViewSettings: ViewSettings,
|
||||
defaultSearchConfig: BookSearchConfig,
|
||||
): string => {
|
||||
return JSON.parse(serializeConfig(config, globalViewSettings, defaultSearchConfig));
|
||||
};
|
||||
@@ -108,8 +108,9 @@ const getLayoutStyles = (
|
||||
color: ${fg};
|
||||
zoom: ${zoomLevel}%;
|
||||
}
|
||||
body *:not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
|
||||
body *:not(a):not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
|
||||
border-color: currentColor !important;
|
||||
${bg === '#ffffff' ? '' : `color: inherit;`}
|
||||
${bg === '#ffffff' ? '' : `background-color: ${bg} !important;`}
|
||||
}
|
||||
svg, img {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl =
|
||||
process.env['NEXT_PUBLIC_SUPABASE_URL'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_URL']!;
|
||||
const supabaseAnonKey =
|
||||
process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY']!;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
|
||||
export const createSupabaseClient = (accessToken?: string) => {
|
||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: {
|
||||
headers: accessToken
|
||||
? {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { SectionItem, TOCItem, CFI, BookDoc } from '@/libs/document';
|
||||
|
||||
export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
|
||||
for (const item of toc) {
|
||||
@@ -15,6 +15,28 @@ export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
|
||||
let left = 0;
|
||||
let right = toc.length - 1;
|
||||
let result: TOCItem | null = null;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const currentCfi = toc[mid]!.cfi || '';
|
||||
const comparison = CFI.compare(currentCfi, cfi);
|
||||
if (comparison === 0) {
|
||||
return toc[mid]!;
|
||||
} else if (comparison < 0) {
|
||||
result = toc[mid]!;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateTocID = (items: TOCItem[], index = 0): number => {
|
||||
items.forEach((item) => {
|
||||
item.id ??= index++;
|
||||
@@ -24,3 +46,22 @@ export const updateTocID = (items: TOCItem[], index = 0): number => {
|
||||
});
|
||||
return index;
|
||||
};
|
||||
|
||||
export const updateTocCFI = (
|
||||
bookDoc: BookDoc,
|
||||
items: TOCItem[],
|
||||
sections: { [id: string]: SectionItem },
|
||||
): void => {
|
||||
items.forEach((item) => {
|
||||
if (item.href) {
|
||||
const id = bookDoc.splitTOCHref(item.href)[0]!;
|
||||
const section = sections[id];
|
||||
if (section) {
|
||||
item.cfi = section.cfi;
|
||||
}
|
||||
}
|
||||
if (item.subitems) {
|
||||
updateTocCFI(bookDoc, item.subitems, sections);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Book,
|
||||
BookConfig,
|
||||
BookFormat,
|
||||
BookNote,
|
||||
BookNoteType,
|
||||
HighlightColor,
|
||||
HighlightStyle,
|
||||
} from '@/types/book';
|
||||
import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
|
||||
|
||||
export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => {
|
||||
const { bookHash, progress, location, searchConfig, viewSettings, updatedAt } =
|
||||
bookConfig as BookConfig;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: bookHash!,
|
||||
location: location,
|
||||
progress: progress && JSON.stringify(progress),
|
||||
search_config: searchConfig && JSON.stringify(searchConfig),
|
||||
view_settings: viewSettings && JSON.stringify(viewSettings),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => {
|
||||
const { book_hash, progress, location, search_config, view_settings, updated_at } = dbBookConfig;
|
||||
return {
|
||||
bookHash: book_hash,
|
||||
location,
|
||||
progress: progress && JSON.parse(progress),
|
||||
searchConfig: search_config && JSON.parse(search_config),
|
||||
viewSettings: view_settings && JSON.parse(view_settings),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
} as BookConfig;
|
||||
};
|
||||
|
||||
export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
const { hash, format, title, author, group, tags, createdAt, updatedAt, deletedAt } =
|
||||
book as Book;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: hash,
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.stringify(tags),
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
const { book_hash, format, title, author, group, tags, created_at, updated_at, deleted_at } =
|
||||
dbBook;
|
||||
|
||||
return {
|
||||
hash: book_hash,
|
||||
format: format as BookFormat,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.parse(tags),
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBookNote => {
|
||||
const { bookHash, id, type, cfi, text, style, color, note, createdAt, updatedAt, deletedAt } =
|
||||
bookNote as BookNote;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: bookHash!,
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
text,
|
||||
style,
|
||||
color,
|
||||
note,
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||
const { book_hash, id, type, cfi, text, style, color, note, created_at, updated_at, deleted_at } =
|
||||
dbBookNote;
|
||||
|
||||
return {
|
||||
bookHash: book_hash,
|
||||
id,
|
||||
type: type as BookNoteType,
|
||||
cfi,
|
||||
text,
|
||||
style: style as HighlightStyle,
|
||||
color: color as HighlightColor,
|
||||
note,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : undefined,
|
||||
};
|
||||
};
|
||||
+1
-2
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"name": "@readest/monorepo",
|
||||
"private": true,
|
||||
"repository": "chrox/readest",
|
||||
"repository": "readest/readest",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"tauri": "pnpm --filter @readest/readest-app tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sindresorhus/tsconfig": "^6.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.1.6",
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 22125a6b58...5bf4163a3a
+1
-1
Submodule packages/tauri updated: 882b0aded1...e349dfe572
Generated
+564
-2398
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user