Compare commits

..

9 Commits

Author SHA1 Message Date
chrox 72c6455fca Release hot-fix version of 0.8.6 2024-12-25 22:06:13 +01:00
Huang Xin 3d0a2b82ba Fix Bookmarks restore from deletion (#43) 2024-12-25 22:02:01 +01:00
Huang Xin 72949e726f Fix deleted bootnotes still shown in sidebar (#42) 2024-12-25 19:39:43 +01:00
Huang Xin 23206bd528 Add context menu for books in Tauri apps (#41)
Currently two menu items in the context menu:
1. Reveal in Folder
2. Delete
2024-12-25 18:43:03 +01:00
chrox 8f26f9a7fa Fix potential 304 Not Modified when pulling in sync 2024-12-25 17:26:08 +01:00
chrox a16ca22814 Refresh session once before logout to ensure logout successfully 2024-12-25 17:26:08 +01:00
Huang Xin 82fb92cf86 Replace deprecated shell-open with opener plugin to open urls and folders (#40) 2024-12-25 16:17:03 +01:00
Huang Xin f76bce5575 Add missing debian section, closes #38 (#39) 2024-12-25 13:07:43 +01:00
Ray-D-Song a23447a813 Add back navigation button and fix dev hydration failed. (#37) 2024-12-25 09:42:37 +01:00
25 changed files with 504 additions and 83 deletions
+7
View File
@@ -82,6 +82,13 @@ This project is a monorepo. The code for the `readest-app` is in the `app/reades
| `pnpm dev-web` | Starts the development server for the web app only |
| `pnpm build-web` | Builds the web app |
Recommended Visual Studio Code plugins for development:
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
- VS Code ESLint extension (dbaeumer.vscode-eslint)
- Prettier - Code formatter (esbenp.prettier-vscode)
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
### When you're done
Check that your code follows the project's style guidelines by running:
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.8.5",
"version": "0.8.6",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -38,6 +38,7 @@
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-http": "^2.2.0",
"@tauri-apps/plugin-log": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.2",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "~2.2.0",
+311 -11
View File
@@ -21,6 +21,7 @@ dependencies = [
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-oauth",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-shell",
@@ -197,7 +198,7 @@ dependencies = [
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
"zbus 5.2.0",
]
[[package]]
@@ -212,6 +213,91 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"slab",
]
[[package]]
name = "async-fs"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059"
dependencies = [
"async-lock",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "async-lock"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
"tracing",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -223,6 +309,24 @@ dependencies = [
"syn 2.0.91",
]
[[package]]
name = "async-signal"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.59.0",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -245,6 +349,12 @@ dependencies = [
"syn 2.0.91",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.83"
@@ -414,6 +524,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "borsh"
version = "1.5.3"
@@ -1486,6 +1609,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@@ -1898,6 +2034,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
[[package]]
name = "hex"
version = "0.4.3"
@@ -3351,6 +3493,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.31"
@@ -3383,6 +3536,21 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "portable-atomic"
version = "1.10.0"
@@ -4252,6 +4420,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.8"
@@ -4906,6 +5085,28 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63ac39033ef1bb4d52da4878c3d8ab6d80b0a569d69208c884e6d4d54eb427b9"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.9",
"url",
"windows",
"zbus 4.4.0",
]
[[package]]
name = "tauri-plugin-os"
version = "2.2.0"
@@ -6569,6 +6770,44 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand 0.8.5",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.2.0"
@@ -6594,9 +6833,22 @@ dependencies = [
"windows-sys 0.59.0",
"winnow 0.6.20",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.2.0",
"zbus_names 4.1.0",
"zvariant 5.1.0",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.91",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -6609,9 +6861,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.91",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.1.0",
"zvariant 5.1.0",
"zvariant_utils 3.0.2",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@@ -6623,7 +6886,7 @@ dependencies = [
"serde",
"static_assertions",
"winnow 0.6.20",
"zvariant",
"zvariant 5.1.0",
]
[[package]]
@@ -6711,6 +6974,19 @@ dependencies = [
"thiserror 2.0.9",
]
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.1.0"
@@ -6723,8 +6999,21 @@ dependencies = [
"static_assertions",
"url",
"winnow 0.6.20",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.1.0",
"zvariant_utils 3.0.2",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.91",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -6737,7 +7026,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.91",
"zvariant_utils",
"zvariant_utils 3.0.2",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.91",
]
[[package]]
+1
View File
@@ -33,6 +33,7 @@ tauri-plugin-devtools = "2.0.0"
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -60,6 +60,7 @@
"process:allow-restart",
"cli:default",
"oauth:allow-start",
"oauth:allow-cancel"
"oauth:allow-cancel",
"opener:default"
]
}
+1
View File
@@ -77,6 +77,7 @@ pub fn run() {
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![start_server])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
+5 -10
View File
@@ -1,7 +1,7 @@
use tauri::menu::MenuEvent;
use tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID};
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
use tauri_plugin_opener::OpenerExt;
pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
let global_menu = app.menu().unwrap();
@@ -27,17 +27,12 @@ pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
}
pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
let opener = app.opener();
if event.id() == "privacy_policy" {
if let Err(e) = app.shell().open("https://readest.com/privacy-policy", None) {
eprintln!("Failed to open privacy policy: {}", e);
}
let _ = opener.open_url("https://readest.com/privacy-policy", None::<&str>);
} else if event.id() == "report_issue" {
if let Err(e) = app.shell().open("mailto:support@bilingify.com", None) {
eprintln!("Failed to open mail client: {}", e);
}
let _ = opener.open_url("https://github.com/readest/readest/issues", None::<&str>);
} else if event.id() == "readest_help" {
if let Err(e) = app.shell().open("https://readest.com/support", None) {
eprintln!("Failed to open support page: {}", e);
}
let _ = opener.open_url("https://readest.com/support", None::<&str>);
}
}
+6 -1
View File
@@ -49,6 +49,11 @@
"macOS": {
"minimumSystemVersion": "12.0"
},
"linux": {
"deb": {
"section": "text"
}
},
"fileAssociations": [
{
"name": "epub",
@@ -126,7 +131,7 @@
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"endpoints": ["https://github.com/chrox/readest/releases/latest/download/latest.json"]
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
}
}
}
+25 -3
View File
@@ -14,9 +14,10 @@ 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 { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { IoArrowBack } from 'react-icons/io5';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -48,6 +49,7 @@ export default function AuthPage() {
const { isDarkMode } = useTheme();
const [port, setPort] = useState<number | null>(null);
const isOAuthServerRunning = useRef(false);
const [isMounted, setIsMounted] = useState(false);
const signIn = async (provider: OAuthProvider) => {
if (!supabase) {
@@ -66,7 +68,7 @@ export default function AuthPage() {
console.error('Authentication error:', error);
return;
}
open(data.url);
openUrl(data.url);
};
const startOAuthServer = async () => {
@@ -134,8 +136,22 @@ export default function AuthPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null;
}
return isTauriAppPlatform() ? (
<div className='flex pt-11'>
<button
onClick={() => router.back()}
className='btn btn-ghost fixed left-3 top-11 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
</button>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<ProviderLogin
provider='google'
@@ -173,7 +189,13 @@ export default function AuthPage() {
</div>
</div>
) : (
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem', paddingTop: '4rem' }}>
<button
onClick={() => router.back()}
className='btn btn-ghost fixed left-10 top-6 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
</button>
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
@@ -8,12 +8,20 @@ import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { useRouter, useSearchParams } from 'next/navigation';
import Image from 'next/image';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { Book, BooksGroup } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { navigateToReader } from '@/utils/nav';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import { isTauriAppPlatform } from '@/services/environment';
type BookshelfItem = Book | BooksGroup;
@@ -51,6 +59,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { deleteBook } = useLibraryStore();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
@@ -123,6 +132,32 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
setShowDeleteAlert(true);
};
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
if (!isTauriAppPlatform()) return;
e.preventDefault();
e.stopPropagation();
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const showBookInFinderMenuItem = await MenuItem.new({
text: fileRevealLabel,
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
revealItemInDir(folder);
},
});
const deleteBookMenuItem = await MenuItem.new({
text: 'Delete',
action: async () => {
deleteBook(envConfig, book);
},
});
const menu = await Menu.new();
menu.append(showBookInFinderMenuItem);
menu.append(deleteBookMenuItem);
menu.popup();
};
return (
<div className='bookshelf'>
<div className='grid grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
@@ -135,6 +170,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
{'format' in item ? (
<div
className='book-item cursor-pointer'
onContextMenu={bookContextMenuHandler.bind(null, item as Book)}
onClick={() => handleBookClick(item.hash)}
>
<div key={(item as Book).hash} className='bg-base-100 shadow-md'>
@@ -41,7 +41,15 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
createdAt: Date.now(),
updatedAt: Date.now(),
};
bookmarks.push(bookmark);
const existingBookmark = bookmarks.find(
(item) => item.type === 'bookmark' && item.cfi === cfi,
);
if (existingBookmark) {
existingBookmark.deletedAt = null;
existingBookmark.updatedAt = Date.now();
} else {
bookmarks.push(bookmark);
}
const updatedConfig = updateBooknotes(bookKey, bookmarks);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
@@ -50,14 +58,15 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
setIsBookmarked(false);
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
const updatedConfig = updateBooknotes(
bookKey,
bookmarks.filter(
(item) =>
item.type !== 'bookmark' ||
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) > 0,
),
);
bookmarks.forEach((item) => {
if (
item.type === 'bookmark' &&
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0
) {
item.deletedAt = Date.now();
}
});
const updatedConfig = updateBooknotes(bookKey, bookmarks);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
@@ -72,7 +81,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
const locationBookmarked = booknotes
.filter((booknote) => booknote.type === 'bookmark')
.filter((booknote) => booknote.type === 'bookmark' && !booknote.deletedAt)
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
setIsBookmarked(locationBookmarked);
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
@@ -104,7 +104,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const detail = (event as CustomEvent).detail;
const { value: cfi, index, range } = detail;
const { booknotes = [] } = config;
const annotations = booknotes.filter((booknote) => booknote.type === 'annotation');
const annotations = booknotes.filter(
(booknote) => booknote.type === 'annotation' && !booknote.deletedAt,
);
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
if (!annotation) return;
const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index };
@@ -235,7 +237,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
const existingIndex = annotations.findIndex(
(annotation) => annotation.cfi === cfi && annotation.type === 'excerpt',
(annotation) =>
annotation.cfi === cfi && annotation.type === 'excerpt' && !annotation.deletedAt,
);
if (existingIndex !== -1) {
annotations[existingIndex] = annotation;
@@ -270,7 +273,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
updatedAt: Date.now(),
};
const existingIndex = annotations.findIndex(
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
(annotation) =>
annotation.cfi === cfi && annotation.type === 'annotation' && !annotation.deletedAt,
);
const views = getViewsById(bookKey.split('-')[0]!);
if (existingIndex !== -1) {
@@ -84,14 +84,18 @@ const Notebook: React.FC = ({}) => {
setNotebookNewAnnotation(null);
};
const handleEditNote = (annotation: BookNote) => {
const handleEditNote = (note: BookNote, isDelete: boolean) => {
if (!sideBarBookKey) return;
const config = getConfig(sideBarBookKey)!;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex((item) => item.id === annotation.id);
const existingIndex = annotations.findIndex((item) => item.id === note.id);
if (existingIndex === -1) return;
annotation.updatedAt = Date.now();
annotations[existingIndex] = annotation;
if (isDelete) {
note.deletedAt = Date.now();
} else {
note.updatedAt = Date.now();
}
annotations[existingIndex] = note;
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
@@ -100,27 +104,16 @@ const Notebook: React.FC = ({}) => {
};
const { handleMouseDown } = useDragBar(handleDragMove);
const deleteNote = (note: BookNote) => {
if (!sideBarBookKey) return;
const config = getConfig(sideBarBookKey);
if (!config) return;
const { booknotes = [] } = config;
const updatedNotes = booknotes.filter((item) => item.id !== note.id);
const updatedConfig = updateBooknotes(sideBarBookKey, updatedNotes);
if (updatedConfig) {
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
}
};
if (!sideBarBookKey) return null;
const config = getConfig(sideBarBookKey);
const { booknotes: allNotes = [] } = config || {};
const annotationNotes = allNotes
.filter((note) => note.type === 'annotation' && note.note)
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
.sort((a, b) => b.createdAt - a.createdAt);
const excerptNotes = allNotes
.filter((note) => note.type === 'excerpt')
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
.sort((a, b) => a.createdAt - b.createdAt);
return isNotebookVisible ? (
@@ -176,7 +169,7 @@ const Notebook: React.FC = ({}) => {
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1em] min-h-[1em] items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
onClick={handleEditNote.bind(null, item, true)}
>
<div className='align-bottom text-xs text-red-400'>Delete</div>
</button>
@@ -192,7 +185,7 @@ const Notebook: React.FC = ({}) => {
)}
</div>
{(notebookNewAnnotation || notebookEditAnnotation) && (
<NoteEditor onSave={handleSaveNote} onEdit={handleEditNote} />
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
)}
<ul className=''>
{annotationNotes.map((item, index) => (
@@ -39,8 +39,12 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
const config = getConfig(bookKey);
if (!config) return;
const { booknotes = [] } = config;
const updatedNotes = booknotes.filter((item) => item.id !== note.id);
const updatedConfig = updateBooknotes(bookKey, updatedNotes);
booknotes.forEach((item) => {
if (item.id === note.id) {
item.deletedAt = Date.now();
}
});
const updatedConfig = updateBooknotes(bookKey, booknotes);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
@@ -22,7 +22,7 @@ const BooknoteView: React.FC<{
const { getConfig } = useBookDataStore();
const config = getConfig(bookKey)!;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => note.type === type);
const booknotes = allNotes.filter((note) => note.type === type && !note.deletedAt);
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
for (const booknote of booknotes) {
@@ -13,15 +13,16 @@ export const useNotesSync = (bookKey: string) => {
const bookHash = bookKey.split('-')[0]!;
useEffect(() => {
if (!config || !user) return;
if (!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 getNewNotes = () => {
if (!config?.location || !user) return [];
const bookNotes = config.booknotes ?? [];
const newNotes = bookNotes.filter(
(note) => lastSyncedAtNotes < note.updatedAt || lastSyncedAtNotes < (note.deletedAt ?? 0),
@@ -29,16 +30,23 @@ export const useNotesSync = (bookKey: string) => {
newNotes.forEach((note) => {
note.bookHash = bookHash;
});
return newNotes;
};
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastSyncTime.current;
if (timeSinceLastSync > SYNC_NOTES_INTERVAL_SEC * 1000) {
lastSyncTime.current = now;
const newNotes = getNewNotes();
syncNotes(newNotes, bookHash, 'both');
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastSyncTime.current = Date.now();
const newNotes = getNewNotes();
syncNotes(newNotes, bookHash, 'both');
syncTimeoutRef.current = null;
},
@@ -49,7 +57,7 @@ export const useNotesSync = (bookKey: string) => {
}, [config]);
useEffect(() => {
if (syncedNotes?.length && config) {
if (syncedNotes?.length && config?.location) {
const newNotes = syncedNotes.filter((note) => note.bookHash === bookHash);
if (!newNotes.length) return;
const oldNotes = config.booknotes ?? [];
@@ -31,7 +31,7 @@ export const useProgressSync = (
};
useEffect(() => {
if (!config || !user) return;
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
return () => {
@@ -43,7 +43,7 @@ export const useProgressSync = (
const lastProgressSyncTime = useRef<number>(0);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!config || !user) return;
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastProgressSyncTime.current;
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
+5 -4
View File
@@ -59,13 +59,14 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
localStorage.setItem('user', JSON.stringify(newUser));
};
const logout = () => {
const logout = async () => {
console.log('Logging out');
setToken(null);
setUser(null);
await supabase.auth.refreshSession();
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
supabase.auth.signOut();
setToken(null);
setUser(null);
};
return (
+9 -3
View File
@@ -1,8 +1,14 @@
import { supabase } from '@/utils/supabase';
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import { isWebAppPlatform } from '@/services/environment';
const BASE_URL = process.env['NODE_ENV'] === 'production' ? READEST_WEB_BASE_URL : '';
// Develop Sync API only in development mode and web platform
// with command `pnpm dev-web`
const SYNC_API_ENDPOINT =
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
? '/api/sync'
: `${READEST_WEB_BASE_URL}/api/sync`;
export type SyncType = 'books' | 'configs' | 'notes';
export type SyncOp = 'push' | 'pull' | 'both';
@@ -32,7 +38,7 @@ export class SyncClient {
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 url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
@@ -55,7 +61,7 @@ export class SyncClient {
const token = await this.getAccessToken();
if (!token) throw new Error('Not authenticated');
const res = await fetch(`${BASE_URL}/api/sync`, {
const res = await fetch(SYNC_API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+9 -1
View File
@@ -101,7 +101,11 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: errorMsg }, { status: 500 });
}
return NextResponse.json(results, { status: 200 });
const response = NextResponse.json(results, { status: 200 });
response.headers.set('Cache-Control', 'no-store');
response.headers.set('Pragma', 'no-cache');
response.headers.delete('ETag');
return response;
} catch (error: unknown) {
console.error(error);
const errorMessage = (error as PostgrestError).message || 'Unknown error';
@@ -146,6 +150,8 @@ export async function POST(req: NextRequest) {
}
if (!serverData) {
// use server updated_at for new records
dbRec.updated_at = new Date().toISOString();
const { data: inserted, error: insertError } = await supabase
.from(table)
.insert(dbRec)
@@ -167,6 +173,8 @@ export async function POST(req: NextRequest) {
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
if (clientIsNewer) {
// use server updated_at for updated records
dbRec.updated_at = new Date().toISOString();
const { data: updated, error: updateError } = await supabase
.from(table)
.update(dbRec)
+1 -1
View File
@@ -132,7 +132,7 @@ export abstract class BaseAppService implements AppService {
const existingBook = books.filter((b) => b.hash === hash)[0];
if (existingBook) {
if (existingBook.deletedAt) {
delete existingBook.deletedAt;
existingBook.deletedAt = null;
}
existingBook.updatedAt = Date.now();
}
+2 -2
View File
@@ -16,7 +16,7 @@ export interface Book {
createdAt: number;
updatedAt: number;
deletedAt?: number;
deletedAt?: number | null;
lastUpdated?: number; // deprecated in favor of updatedAt
}
@@ -39,7 +39,7 @@ export interface BookNote {
createdAt: number;
updatedAt: number;
deletedAt?: number;
deletedAt?: number | null;
}
export interface BookLayout {
+8
View File
@@ -0,0 +1,8 @@
export const FILE_REVEAL_LABELS = {
macos: 'Reveal in Finder',
windows: 'Reveal in File Explorer',
linux: 'Reveal in Folder',
default: 'Reveal in Folder',
};
export type FILE_REVEAL_PLATFORMS = keyof typeof FILE_REVEAL_LABELS;
+5 -4
View File
@@ -50,7 +50,7 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
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,
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
};
};
@@ -67,7 +67,7 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
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,
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
};
};
@@ -87,7 +87,8 @@ export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBook
note,
created_at: new Date(createdAt).toISOString(),
updated_at: new Date(updatedAt).toISOString(),
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : undefined,
// note that only null deleted_at is updated to the database, undefined is not
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
};
};
@@ -106,6 +107,6 @@ export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
note,
createdAt: new Date(created_at!).getTime(),
updatedAt: new Date(updated_at!).getTime(),
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : undefined,
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
};
};
+10
View File
@@ -62,6 +62,9 @@ importers:
'@tauri-apps/plugin-log':
specifier: ^2.2.0
version: 2.2.0
'@tauri-apps/plugin-opener':
specifier: ^2.2.2
version: 2.2.2
'@tauri-apps/plugin-os':
specifier: ^2.2.0
version: 2.2.0
@@ -773,6 +776,9 @@ packages:
'@tauri-apps/plugin-log@2.2.0':
resolution: {integrity: sha512-g6CsQAR1lsm5ABSZZxpM/iCn86GrMDTTlhj7GPkZkYBRSm3+WczfOAl7SV7HDn77tOKCzhZffwI5uHfRoHutrw==}
'@tauri-apps/plugin-opener@2.2.2':
resolution: {integrity: sha512-E/XIHKqGV+FT8PDdkfMETmgPUxcR79Rk8USuzbadD/ZdvsKCfQR5q+6rpZC9zEnG2wzi9lVQM4D3xwrtGGIB8A==}
'@tauri-apps/plugin-os@2.2.0':
resolution: {integrity: sha512-HszbCdbisMlu5QhCNAN8YIWyz2v33abAWha6+uvV2CKX8P5VSct/y+kEe22JeyqrxCnWlQ3DRx7s49Byg7/0EA==}
@@ -3454,6 +3460,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-opener@2.2.2':
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-os@2.2.0':
dependencies:
'@tauri-apps/api': 2.1.1