Files
readest/apps/readest-app/src/components/Toast.tsx
T
Huang Xin 99b9adfe85 refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract wire envelope module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract pure merge module with law tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): add FileSyncProvider and LocalStore interfaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): FileSyncEngine orchestration over a provider

Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider +
LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is
retargeted to drive the engine through a fake provider + store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): move WebDAV client + connect settings under providers/webdav

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): WebDAVProvider implementing FileSyncProvider

Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral
FileSyncError, and owns Tauri streaming upload/download. Adds a
provider-conformance suite future backends can run against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): shared appService-backed LocalStore bridge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reader): drive WebDAV sync through FileSyncEngine

Construct a WebDAVProvider + shared LocalStore + engine once per hook; the
inline buffered/streaming book-file loader collapses into the provider +
store, so the hook no longer imports tauriUpload or the file path helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(settings): drive WebDAV library sync + browse through the provider

WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls
engine.syncLibrary; the ~170-line inline callback block (buffered/streaming
loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for
the engine-level deleteRemoteBookDir cleanup helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider

Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client
and connect-settings shims). The superseded webdav-metadata-sync test is
replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir
through a WebDAVProvider and asserts FileSyncError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): hydrate library before WebDAV Sync now to prevent clobber

Sync now while the library store was unloaded (app launched into reader/
settings without mounting the Library view) merged the engine's
addBookToLibrary / updateBookMetadata against an empty in-memory library,
persisting a downloaded book or a metadata update as the entire library and
wiping what was on disk. Pre-existing bug surfaced during the file-sync
review. Hydrate the store in handleSyncNow and harden the store bridge with a
load-if-unloaded guard (mirrors useLibraryStore.updateBooks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): make listDirectory honor the FileSyncError contract

listDirectory threw a plain Error (and let raw fetch failures escape), so
WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw
the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED /
NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to
the provider-conformance suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sync): cover streaming upload, discovery/download, and receive paths

The metadata-sync gate only exercised the buffered metadata + config-merge
paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit +
one-shot retry), remote-only discovery -> streaming download -> addBook, and
the receive strategy (pull-only, no config or index writes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): incremental WebDAV Sync now + bounded concurrency

Sync now was a full walk of every book each run (675 round-trips even when
nothing changed). Default to incremental: diff the local library against the
shared library.json index per hash and only process books whose local copy is
newer (or absent). book.updatedAt bumps on every progress/notes/metadata save
(bookDataStore.saveConfig), so the index is a reliable per-book change marker.
Remote-newer books pull their config in the reconcile pass so peer progress
still propagates. A new 'Full Sync' toggle (default off) re-checks everything.

Also run the reconcile / download / push phases over a bounded worker pool
(default concurrency 4) instead of one book at a time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): simplify Sync now toast to a single book count

The completion toast built a multi-line success bullet list (downloaded /
pulled / pushed / uploaded). Replace it with the same single-line info toast
the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced
counter to the engine result (a Set of distinct hashes touched in any
direction, since the per-action counters overlap under Full Sync). Failures
still surface as a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): raise toasts above modals so they aren't hidden by open dialogs

Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal
(z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now')
was buried. The documented overlay scale already places toast at 130; the
component just hadn't followed it. Move the toast to z-[130] and extend the
zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:57:52 +02:00

187 lines
6.5 KiB
TypeScript

import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { eventDispatcher } from '@/utils/event';
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export const Toast = () => {
const { safeAreaInsets } = useThemeStore();
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<ToastType>('info');
const [toastTimeout, setToastTimeout] = useState(5000);
const [messageClass, setMessageClass] = useState('');
const [isVisible, setIsVisible] = useState(false);
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastClassMap = {
info: 'toast-info toast-center toast-middle',
success: 'toast-success toast-top sm:toast-end toast-center',
warning: 'toast-warning toast-top sm:toast-end toast-center',
error: 'toast-error toast-top sm:toast-end toast-center',
};
const alertClassMap = {
info: 'alert-primary border-base-300',
success: 'alert-success not-eink:from-green-500 not-eink:to-emerald-500',
warning: 'alert-warning not-eink:from-amber-500 not-eink:to-orange-500',
error: 'alert-error not-eink:from-red-500 not-eink:to-rose-500',
};
const iconMap = {
info: (
<svg className='h-5 w-5' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
),
success: (
<svg className='h-5 w-5' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
clipRule='evenodd'
/>
</svg>
),
warning: (
<svg className='h-5 w-5' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
),
error: (
<svg className='h-5 w-5' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
clipRule='evenodd'
/>
</svg>
),
};
useEffect(() => {
if (toastMessage) {
setTimeout(() => {
setIsVisible(true);
}, 0);
}
}, [toastMessage]);
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
if (toastMessage) {
const timeout = setTimeout(() => {
setIsVisible(false);
setTimeout(() => setToastMessage(''), 300);
}, toastTimeout);
toastDismissTimeout.current = timeout;
return () => {
if (timeout) clearTimeout(timeout);
};
}
return;
}, [toastMessage, toastTimeout]);
const handleShowToast = async (event: CustomEvent) => {
const { message, type = 'info', timeout, className = '', callback = null } = event.detail;
setToastMessage(message);
setToastType(type);
if (timeout) setToastTimeout(timeout);
if (callback && typeof callback === 'function') {
setTimeout(() => callback(), timeout || 5000);
}
setMessageClass(className);
};
useEffect(() => {
eventDispatcher.on('toast', handleShowToast);
return () => {
eventDispatcher.off('toast', handleShowToast);
};
}, []);
const handleDismiss = () => {
setIsVisible(false);
setTimeout(() => setToastMessage(''), 300);
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
return (
toastMessage && (
<div
className={clsx(
'toast z-[130] w-auto max-w-screen-sm transition-all duration-300',
toastClassMap[toastType],
isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0',
)}
style={{
top: toastClassMap[toastType].includes('toast-top')
? `${(safeAreaInsets?.top || 0) + 44}px`
: undefined,
}}
>
<div
className={clsx(
'alert flex items-center gap-3 shadow-2xl backdrop-blur-sm',
'min-h-0 rounded-2xl px-5 py-4',
'not-eink:bg-gradient-to-r border-0',
alertClassMap[toastType],
'eink:bg-base-100 eink:border eink:border-base-content',
toastType !== 'info' && 'text-white',
)}
>
{/* Icon */}
<div className='flex-shrink-0'>{iconMap[toastType]}</div>
{/* Message */}
<span
className={clsx(
'max-h-[50vh] flex-1 overflow-y-auto',
'font-sans text-base font-medium leading-snug sm:text-sm',
toastType === 'info'
? 'max-w-[60vw] truncate sm:max-w-[80vw]'
: 'min-w-[60vw] max-w-[80vw] whitespace-normal break-words sm:min-w-40 sm:max-w-80',
messageClass,
)}
>
{toastMessage.split('\n').map((line, idx) => (
<React.Fragment key={idx}>
{line || <>&nbsp;</>}
{idx < toastMessage.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</span>
{/* Close button */}
<button
onClick={handleDismiss}
className={clsx(
'flex-shrink-0 rounded-lg p-1 transition-colors',
toastType === 'info'
? 'hover:bg-base-300 hidden'
: 'hover:bg-white/20 active:bg-white/30',
)}
aria-label='Dismiss'
>
<svg className='h-4 w-4' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'
clipRule='evenodd'
/>
</svg>
</button>
</div>
</div>
)
);
};