mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: improve offline PWA resilience
This commit is contained in:
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -237,6 +238,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<NetworkStatusBadge />
|
||||
<div className="user-chip">
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -83,7 +84,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'locked') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -132,7 +133,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'register') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_create_account')}>
|
||||
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -216,7 +217,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_log_in')}>
|
||||
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Wifi, WifiOff } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
browserReportsOffline,
|
||||
getCurrentNetworkStatus,
|
||||
probeNodeWardenService,
|
||||
setCurrentNetworkStatus,
|
||||
subscribeNetworkStatus,
|
||||
type NetworkStatus,
|
||||
} from '@/lib/network-status';
|
||||
|
||||
const STATUS_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
function statusLabel(status: NetworkStatus): string {
|
||||
if (status === 'online') return t('txt_online');
|
||||
return t('txt_offline');
|
||||
}
|
||||
|
||||
export default function NetworkStatusBadge() {
|
||||
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||
const label = statusLabel(status);
|
||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
|
||||
const checkService = async () => {
|
||||
if (browserReportsOffline()) {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return;
|
||||
}
|
||||
const reachable = await probeNodeWardenService();
|
||||
if (!cancelled) {
|
||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNextCheck = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
}, STATUS_CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const handleOnline = () => {
|
||||
void checkService();
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setCurrentNetworkStatus('offline');
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') void checkService();
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeNetworkStatus(setStatus);
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
window.addEventListener('focus', handleOnline);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
window.removeEventListener('focus', handleOnline);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`network-status-badge ${status}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className="network-status-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
|
||||
interface StandalonePageFrameProps {
|
||||
title: string;
|
||||
eyebrow?: ComponentChildren;
|
||||
titleAccessory?: ComponentChildren;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
|
||||
<div className="auth-card">
|
||||
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
<div className="standalone-title-row">
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
{props.titleAccessory}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -716,6 +716,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -729,6 +731,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setPendingDelete(null);
|
||||
cancelEdit();
|
||||
if (isMobileLayout) setMobilePanel('list');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -741,6 +745,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === cipher.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -760,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
}
|
||||
setSelectedMap({});
|
||||
setBulkDeleteOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -776,6 +784,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkMove(ids, folderId);
|
||||
setSelectedMap({});
|
||||
setMoveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -785,6 +795,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onRefresh();
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -819,6 +831,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onCreateFolder(newFolderName);
|
||||
setCreateFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -833,6 +847,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setPendingDeleteFolder(null);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -850,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -864,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkRestore(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -878,6 +898,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -892,6 +914,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
delete next[cipher.id];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -907,6 +931,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkArchive(ids);
|
||||
setSelectedMap({});
|
||||
setBulkArchiveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -921,6 +947,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkUnarchive(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -935,6 +963,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setDeleteAllFoldersOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
subscribeWebsiteIconStatus,
|
||||
} from '@/lib/website-icon-cache';
|
||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
|
||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||
@@ -26,8 +27,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
||||
const [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus);
|
||||
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
|
||||
|
||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
setShouldLoad(true);
|
||||
@@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
useEffect(() => {
|
||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||
if (demoIconUrl) return;
|
||||
if (networkStatus !== 'online') return;
|
||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||
beginWebsiteIconLoad(host, src);
|
||||
}, [demoIconUrl, host, src, shouldLoad, status]);
|
||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
||||
|
||||
if (demoIconUrl) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user