99b9adfe85
* 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>
187 lines
6.5 KiB
TypeScript
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 || <> </>}
|
|
{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>
|
|
)
|
|
);
|
|
};
|