mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance mobile layout and accessibility across components
- Added mobile layout support in AdminPage, SecurityDevicesPage, SendsPage, and VaultPage. - Implemented responsive design adjustments including mobile sidebar and panel transitions. - Updated table structures to include data labels for better accessibility. - Introduced new translations for mobile-specific UI elements. - Enhanced styles for mobile views, including button adjustments and sidebar behaviors.
This commit is contained in:
+279
-116
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowUpDown, Cloud, Clock3, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import AuthViews from '@/components/AuthViews';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
@@ -99,6 +99,8 @@ const SEND_KEY_SALT = 'bitwarden-send';
|
||||
const SEND_KEY_PURPOSE = 'send';
|
||||
const IMPORT_ROUTE = '/help/import-export';
|
||||
const IMPORT_ROUTE_ALIASES = new Set(['/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export']);
|
||||
const SETTINGS_HOME_ROUTE = '/settings';
|
||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||
|
||||
function looksLikeCipherString(value: string): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
@@ -318,11 +320,25 @@ export default function App() {
|
||||
} | null>(null);
|
||||
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
const [mobileLayout, setMobileLayout] = useState(false);
|
||||
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
|
||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const media = window.matchMedia('(max-width: 900px)');
|
||||
const sync = () => setMobileLayout(media.matches);
|
||||
sync();
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', sync);
|
||||
return () => media.removeEventListener('change', sync);
|
||||
}
|
||||
media.addListener(sync);
|
||||
return () => media.removeListener(sync);
|
||||
}, []);
|
||||
|
||||
function setSession(next: SessionState | null) {
|
||||
setSessionState(next);
|
||||
saveSession(next);
|
||||
@@ -1581,6 +1597,27 @@ export default function App() {
|
||||
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
||||
const isPublicSendRoute = !!publicSendMatch;
|
||||
const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location);
|
||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||
const mobilePrimaryRoute =
|
||||
location === '/sends'
|
||||
? '/sends'
|
||||
: location === '/vault/totp'
|
||||
? '/vault/totp'
|
||||
: location === '/vault'
|
||||
? '/vault'
|
||||
: '/settings';
|
||||
const currentPageTitle = (() => {
|
||||
if (location === '/vault/totp') return t('txt_verification_code');
|
||||
if (location === '/sends') return t('nav_sends');
|
||||
if (location === '/admin') return t('nav_admin_panel');
|
||||
if (location === '/security/devices') return t('nav_device_management');
|
||||
if (location === '/help') return t('nav_backup_strategy');
|
||||
if (isImportRoute) return t('nav_import_export');
|
||||
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
|
||||
if (location === SETTINGS_HOME_ROUTE) return t('txt_settings');
|
||||
return t('nav_my_vault');
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||
@@ -1598,6 +1635,12 @@ export default function App() {
|
||||
}
|
||||
}, [phase, profile?.role, location, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) {
|
||||
navigate(SETTINGS_ACCOUNT_ROUTE);
|
||||
}
|
||||
}, [phase, mobileLayout, location, navigate]);
|
||||
|
||||
if (jwtWarning) {
|
||||
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
||||
}
|
||||
@@ -1709,7 +1752,8 @@ export default function App() {
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||
<span>NodeWarden</span>
|
||||
<span className="brand-name">NodeWarden</span>
|
||||
<span className="mobile-page-title">{currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<div className="user-chip">
|
||||
@@ -1719,6 +1763,20 @@ export default function App() {
|
||||
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
||||
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||
</button>
|
||||
{showSidebarToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||
aria-label={sidebarToggleTitle}
|
||||
title={sidebarToggleTitle}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||
>
|
||||
<Folder size={16} className="btn-icon" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={handleLock}>
|
||||
<Lock size={14} className="btn-icon" />
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
||||
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
||||
</button>
|
||||
@@ -1745,7 +1803,7 @@ export default function App() {
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/settings" className={`side-link ${location === '/settings' ? 'active' : ''}`}>
|
||||
<Link href={SETTINGS_ACCOUNT_ROUTE} className={`side-link ${location === SETTINGS_ACCOUNT_ROUTE ? 'active' : ''}`}>
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
@@ -1800,127 +1858,203 @@ export default function App() {
|
||||
onDownloadAttachment={downloadVaultAttachment}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={SETTINGS_ACCOUNT_ROUTE}>
|
||||
{profile && (
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<SettingsPage
|
||||
profile={profile}
|
||||
totpEnabled={!!totpStatusQuery.data?.enabled}
|
||||
onChangePassword={changePasswordAction}
|
||||
onEnableTotp={async (secret, token) => {
|
||||
await enableTotpAction(secret, token);
|
||||
await totpStatusQuery.refetch();
|
||||
}}
|
||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||
onGetRecoveryCode={getRecoveryCodeAction}
|
||||
onNotify={pushToast}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{profile && (
|
||||
<SettingsPage
|
||||
profile={profile}
|
||||
totpEnabled={!!totpStatusQuery.data?.enabled}
|
||||
onChangePassword={changePasswordAction}
|
||||
onEnableTotp={async (secret, token) => {
|
||||
await enableTotpAction(secret, token);
|
||||
await totpStatusQuery.refetch();
|
||||
}}
|
||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||
onGetRecoveryCode={getRecoveryCodeAction}
|
||||
onNotify={pushToast}
|
||||
/>
|
||||
<section className="card mobile-settings-card">
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={SETTINGS_ACCOUNT_ROUTE} className="mobile-settings-link">
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href={IMPORT_ROUTE} className="mobile-settings-link">
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{profile.role === 'admin' && (
|
||||
<Link href="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{profile.role === 'admin' && (
|
||||
<Link href="/help" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={handleLogout}>
|
||||
<LogOut size={14} className="btn-icon" />
|
||||
{t('txt_sign_out')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/security/devices">
|
||||
<SecurityDevicesPage
|
||||
devices={authorizedDevicesQuery.data || []}
|
||||
loading={authorizedDevicesQuery.isFetching}
|
||||
onRefresh={() => void refreshAuthorizedDevices()}
|
||||
onRevokeTrust={(device) => {
|
||||
setConfirm({
|
||||
title: t('txt_revoke_device_authorization'),
|
||||
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void revokeDeviceTrustAction(device);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRemoveDevice={(device) => {
|
||||
setConfirm({
|
||||
title: t('txt_remove_device'),
|
||||
message: t('txt_remove_device_name_and_clear_its_2fa_trust', { name: device.name }),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void removeDeviceAction(device);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRevokeAll={() => {
|
||||
setConfirm({
|
||||
title: t('txt_revoke_all_trusted_devices'),
|
||||
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void revokeAllDeviceTrustAction();
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<SecurityDevicesPage
|
||||
devices={authorizedDevicesQuery.data || []}
|
||||
loading={authorizedDevicesQuery.isFetching}
|
||||
onRefresh={() => void refreshAuthorizedDevices()}
|
||||
onRevokeTrust={(device) => {
|
||||
setConfirm({
|
||||
title: t('txt_revoke_device_authorization'),
|
||||
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void revokeDeviceTrustAction(device);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRemoveDevice={(device) => {
|
||||
setConfirm({
|
||||
title: t('txt_remove_device'),
|
||||
message: t('txt_remove_device_name_and_clear_its_2fa_trust', { name: device.name }),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void removeDeviceAction(device);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRevokeAll={() => {
|
||||
setConfirm({
|
||||
title: t('txt_revoke_all_trusted_devices'),
|
||||
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void revokeAllDeviceTrustAction();
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
<AdminPage
|
||||
currentUserId={profile?.id || ''}
|
||||
users={usersQuery.data || []}
|
||||
invites={invitesQuery.data || []}
|
||||
onRefresh={() => {
|
||||
void usersQuery.refetch();
|
||||
void invitesQuery.refetch();
|
||||
}}
|
||||
onCreateInvite={async (hours) => {
|
||||
await createInvite(authedFetch, hours);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', t('txt_invite_created'));
|
||||
}}
|
||||
onDeleteAllInvites={async () => {
|
||||
setConfirm({
|
||||
title: t('txt_delete_all_invites'),
|
||||
message: t('txt_delete_all_invite_codes_active_inactive'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void (async () => {
|
||||
await deleteAllInvites(authedFetch);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', t('txt_all_invites_deleted'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
onToggleUserStatus={async (userId, status) => {
|
||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||
await usersQuery.refetch();
|
||||
pushToast('success', t('txt_user_status_updated'));
|
||||
}}
|
||||
onDeleteUser={async (userId) => {
|
||||
setConfirm({
|
||||
title: t('txt_delete_user'),
|
||||
message: t('txt_delete_this_user_and_all_user_data'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void (async () => {
|
||||
await deleteUser(authedFetch, userId);
|
||||
await usersQuery.refetch();
|
||||
pushToast('success', t('txt_user_deleted'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRevokeInvite={async (code) => {
|
||||
await revokeInvite(authedFetch, code);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', t('txt_invite_revoked'));
|
||||
}}
|
||||
/>
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<AdminPage
|
||||
currentUserId={profile?.id || ''}
|
||||
users={usersQuery.data || []}
|
||||
invites={invitesQuery.data || []}
|
||||
onRefresh={() => {
|
||||
void usersQuery.refetch();
|
||||
void invitesQuery.refetch();
|
||||
}}
|
||||
onCreateInvite={async (hours) => {
|
||||
await createInvite(authedFetch, hours);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', t('txt_invite_created'));
|
||||
}}
|
||||
onDeleteAllInvites={async () => {
|
||||
setConfirm({
|
||||
title: t('txt_delete_all_invites'),
|
||||
message: t('txt_delete_all_invite_codes_active_inactive'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void (async () => {
|
||||
await deleteAllInvites(authedFetch);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', t('txt_all_invites_deleted'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
onToggleUserStatus={async (userId, status) => {
|
||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||
await usersQuery.refetch();
|
||||
pushToast('success', t('txt_user_status_updated'));
|
||||
}}
|
||||
onDeleteUser={async (userId) => {
|
||||
setConfirm({
|
||||
title: t('txt_delete_user'),
|
||||
message: t('txt_delete_this_user_and_all_user_data'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void (async () => {
|
||||
await deleteUser(authedFetch, userId);
|
||||
await usersQuery.refetch();
|
||||
pushToast('success', t('txt_user_deleted'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRevokeInvite={async (code) => {
|
||||
await revokeInvite(authedFetch, code);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', t('txt_invite_revoked'));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path={IMPORT_ROUTE}>
|
||||
<ImportPage
|
||||
onImport={handleImportAction}
|
||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||
onNotify={pushToast}
|
||||
folders={decryptedFolders}
|
||||
onExport={handleExportAction}
|
||||
/>
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ImportPage
|
||||
onImport={handleImportAction}
|
||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||
onNotify={pushToast}
|
||||
folders={decryptedFolders}
|
||||
onExport={handleExportAction}
|
||||
/>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/tools/import">
|
||||
<ImportPage
|
||||
@@ -1974,12 +2108,41 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="/help">
|
||||
{profile?.role === 'admin' ? (
|
||||
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
|
||||
</div>
|
||||
) : null}
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav className="mobile-tabbar" aria-label={t('txt_menu')}>
|
||||
<Link href="/vault" className={`mobile-tab ${mobilePrimaryRoute === '/vault' ? 'active' : ''}`}>
|
||||
<KeyRound size={18} />
|
||||
<span>{t('nav_my_vault')}</span>
|
||||
</Link>
|
||||
<Link href="/vault/totp" className={`mobile-tab ${mobilePrimaryRoute === '/vault/totp' ? 'active' : ''}`}>
|
||||
<Clock3 size={18} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`mobile-tab ${mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={18} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
</Link>
|
||||
<Link href="/settings" className={`mobile-tab ${mobilePrimaryRoute === '/settings' ? 'active' : ''}`}>
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('txt_settings')}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,11 +56,11 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
<tbody>
|
||||
{props.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.name || t('txt_dash')}</td>
|
||||
<td>{roleText(user.role)}</td>
|
||||
<td>{statusText(user.status)}</td>
|
||||
<td>
|
||||
<td data-label={t('txt_email')}>{user.email}</td>
|
||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||
<td data-label={t('txt_status')}>{statusText(user.status)}</td>
|
||||
<td data-label={t('txt_actions')}>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -126,10 +126,10 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
<tbody>
|
||||
{pagedInvites.map((invite) => (
|
||||
<tr key={invite.code}>
|
||||
<td>{invite.code}</td>
|
||||
<td>{statusText(invite.status)}</td>
|
||||
<td>{formatExpiresAt(invite.expiresAt)}</td>
|
||||
<td>
|
||||
<td data-label={t('txt_code')}>{invite.code}</td>
|
||||
<td data-label={t('txt_status')}>{statusText(invite.status)}</td>
|
||||
<td data-label={t('txt_expires_at')}>{formatExpiresAt(invite.expiresAt)}</td>
|
||||
<td data-label={t('txt_actions')}>
|
||||
<div className="actions invite-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -79,14 +79,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<tbody>
|
||||
{props.devices.map((device) => (
|
||||
<tr key={device.identifier}>
|
||||
<td>
|
||||
<td data-label={t('txt_device')}>
|
||||
<div>{device.name || t('txt_unknown_device')}</div>
|
||||
<div className="muted-inline">{device.identifier}</div>
|
||||
</td>
|
||||
<td>{mapDeviceTypeName(device.type)}</td>
|
||||
<td>{formatDateTime(device.creationDate)}</td>
|
||||
<td>{formatDateTime(device.revisionDate)}</td>
|
||||
<td>
|
||||
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
||||
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
|
||||
<td data-label={t('txt_trusted_until')}>
|
||||
{device.trusted ? (
|
||||
<div className="trusted-cell">
|
||||
<Clock3 size={13} />
|
||||
@@ -96,7 +96,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<span className="muted-inline">{t('txt_not_trusted')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label={t('txt_actions')}>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { CheckCheck, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||
import type { Send, SendDraft } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SendsPageProps {
|
||||
|
||||
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
||||
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
|
||||
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||
if (!iso) return String(fallback);
|
||||
@@ -67,6 +68,9 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||
const [isMobileLayout, setIsMobileLayout] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
||||
@@ -75,6 +79,27 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const media = window.matchMedia(MOBILE_LAYOUT_QUERY);
|
||||
const sync = () => setIsMobileLayout(media.matches);
|
||||
sync();
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', sync);
|
||||
return () => media.removeEventListener('change', sync);
|
||||
}
|
||||
media.addListener(sync);
|
||||
return () => media.removeListener(sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleSidebar = () => {
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
};
|
||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
|
||||
@@ -83,6 +108,19 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
}
|
||||
}, [autoCopyLink]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileLayout) {
|
||||
setMobilePanel('list');
|
||||
setMobileSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
if (isEditing) {
|
||||
setMobilePanel('edit');
|
||||
} else if (!selectedId) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
}, [isMobileLayout, isEditing, selectedId]);
|
||||
|
||||
const filteredSends = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return props.sends.filter((send) => {
|
||||
@@ -141,6 +179,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
setIsCreating(false);
|
||||
setDraft(null);
|
||||
setShowPassword(false);
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -153,6 +192,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
if (selectedId === send.id) setSelectedId(null);
|
||||
setIsEditing(false);
|
||||
setDraft(null);
|
||||
if (isMobileLayout) setMobilePanel('list');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -176,8 +216,17 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vault-grid">
|
||||
<aside className="sidebar">
|
||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
|
||||
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
||||
{isMobileLayout && (
|
||||
<div className="mobile-sidebar-head">
|
||||
<div className="mobile-sidebar-title">{t('txt_all_sends')}</div>
|
||||
<button type="button" className="mobile-sidebar-close" onClick={() => setMobileSidebarOpen(false)} aria-label={t('txt_close')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-block">
|
||||
<div className="sidebar-title">{t('txt_all_sends')}</div>
|
||||
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
||||
@@ -206,7 +255,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
value={search}
|
||||
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -235,16 +284,20 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small"
|
||||
className="btn btn-primary small mobile-fab-trigger"
|
||||
disabled={busy}
|
||||
aria-label={t('txt_add')}
|
||||
title={t('txt_add')}
|
||||
onClick={() => {
|
||||
setIsCreating(true);
|
||||
setIsEditing(true);
|
||||
setDraft(buildDefaultDraft());
|
||||
setShowPassword(false);
|
||||
if (isMobileLayout) setMobilePanel('edit');
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" /> {t('txt_add')}
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-panel">
|
||||
@@ -269,6 +322,8 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setDraft(null);
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="list-icon-wrap">
|
||||
@@ -289,7 +344,29 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-col">
|
||||
<section className={`detail-col ${isMobileLayout ? 'mobile-detail-sheet' : ''} ${isMobileLayout && mobilePanel !== 'list' ? 'open' : ''}`}>
|
||||
{isMobileLayout && mobilePanel !== 'list' && (
|
||||
<div className="mobile-panel-head">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small mobile-panel-back"
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setDraft(null);
|
||||
setShowPassword(false);
|
||||
setMobilePanel(selectedSend ? 'detail' : 'list');
|
||||
} else {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} className="btn-icon" />
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||
@@ -369,7 +446,18 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setDraft(null);
|
||||
setShowPassword(false);
|
||||
if (isMobileLayout) setMobilePanel(selectedSend ? 'detail' : 'list');
|
||||
}}
|
||||
>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronLeft,
|
||||
Clipboard,
|
||||
CreditCard,
|
||||
Download,
|
||||
@@ -76,6 +77,7 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
||||
];
|
||||
|
||||
const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
{ value: 'created', label: t('txt_sort_created') },
|
||||
@@ -398,12 +400,36 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||
const [repromptPassword, setRepromptPassword] = useState('');
|
||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||
const [isMobileLayout, setIsMobileLayout] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const sortMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const sshSeedTicketRef = useRef(0);
|
||||
const sshFingerprintTicketRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const media = window.matchMedia(MOBILE_LAYOUT_QUERY);
|
||||
const sync = () => setIsMobileLayout(media.matches);
|
||||
sync();
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', sync);
|
||||
return () => media.removeEventListener('change', sync);
|
||||
}
|
||||
media.addListener(sync);
|
||||
return () => media.removeListener(sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleSidebar = () => {
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
};
|
||||
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onQuickAdd = () => {
|
||||
startCreate(1);
|
||||
@@ -475,6 +501,19 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
setRepromptOpen(false);
|
||||
}, [selectedCipherId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileLayout) {
|
||||
setMobilePanel('list');
|
||||
setMobileSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
if (isEditing) {
|
||||
setMobilePanel('edit');
|
||||
} else if (!selectedCipherId) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
}, [isMobileLayout, isEditing, selectedCipherId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchComposing) return;
|
||||
const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90);
|
||||
@@ -613,6 +652,8 @@ function folderName(id: string | null | undefined): string {
|
||||
setLocalError('');
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('edit');
|
||||
setMobileSidebarOpen(false);
|
||||
if (type === 5) void seedSshDefaults();
|
||||
}
|
||||
|
||||
@@ -625,15 +666,19 @@ function folderName(id: string | null | undefined): string {
|
||||
setLocalError('');
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('edit');
|
||||
setMobileSidebarOpen(false);
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
||||
setDraft(null);
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setLocalError('');
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||
}
|
||||
|
||||
function updateDraft(patch: Partial<VaultDraft>): void {
|
||||
@@ -755,6 +800,7 @@ function folderName(id: string | null | undefined): string {
|
||||
setLocalError('');
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -767,6 +813,7 @@ function folderName(id: string | null | undefined): string {
|
||||
await props.onDelete(pendingDelete);
|
||||
setPendingDelete(null);
|
||||
cancelEdit();
|
||||
if (isMobileLayout) setMobilePanel('list');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -862,8 +909,17 @@ function folderName(id: string | null | undefined): string {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vault-grid">
|
||||
<aside className="sidebar">
|
||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />}
|
||||
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
||||
{isMobileLayout && (
|
||||
<div className="mobile-sidebar-head">
|
||||
<div className="mobile-sidebar-title">{t('txt_folders')}</div>
|
||||
<button type="button" className="mobile-sidebar-close" onClick={() => setMobileSidebarOpen(false)} aria-label={t('txt_close')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-block">
|
||||
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'all' })}>
|
||||
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||
@@ -978,7 +1034,7 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -998,9 +1054,15 @@ function folderName(id: string | null | undefined): string {
|
||||
>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
<div className="create-menu-wrap" ref={createMenuRef}>
|
||||
<button type="button" className="btn btn-primary small" onClick={() => setCreateMenuOpen((x) => !x)}>
|
||||
<Plus size={14} className="btn-icon" /> {t('txt_add')}
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small mobile-fab-trigger"
|
||||
aria-label={t('txt_add')}
|
||||
title={t('txt_add')}
|
||||
onClick={() => setCreateMenuOpen((x) => !x)}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
{createMenuOpen && (
|
||||
<div className="create-menu">
|
||||
@@ -1056,6 +1118,8 @@ function folderName(id: string | null | undefined): string {
|
||||
}
|
||||
setSelectedCipherId(cipher.id);
|
||||
setRepromptApprovedCipherId(null);
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="list-icon-wrap">
|
||||
@@ -1072,7 +1136,22 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-col">
|
||||
<section className={`detail-col ${isMobileLayout ? 'mobile-detail-sheet' : ''} ${isMobileLayout && mobilePanel !== 'list' ? 'open' : ''}`}>
|
||||
{isMobileLayout && mobilePanel !== 'list' && (
|
||||
<div className="mobile-panel-head">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small mobile-panel-back"
|
||||
onClick={() => {
|
||||
if (isEditing) cancelEdit();
|
||||
else setMobilePanel('list');
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} className="btn-icon" />
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<>
|
||||
<div className="card">
|
||||
|
||||
+12
-6
@@ -218,6 +218,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_log_in: "Log In",
|
||||
txt_log_out: "Log Out",
|
||||
txt_lock: "Lock",
|
||||
txt_menu: "Menu",
|
||||
txt_settings: "Settings",
|
||||
txt_back: "Back",
|
||||
txt_login: "Login",
|
||||
txt_login_credentials: "Login Credentials",
|
||||
txt_login_failed: "Login failed",
|
||||
@@ -395,7 +398,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
};
|
||||
|
||||
const zhCNOverrides: Record<string, string> = {
|
||||
nav_my_vault: '我的保险库',
|
||||
nav_my_vault: '我的密码库',
|
||||
nav_sends: 'Send',
|
||||
nav_admin_panel: '用户管理',
|
||||
nav_account_settings: '账户设置',
|
||||
@@ -429,7 +432,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_create_account: '创建账户',
|
||||
txt_back_to_login: '返回登录',
|
||||
txt_unlock: '解锁',
|
||||
txt_unlock_vault: '解锁保险库',
|
||||
txt_unlock_vault: '解锁密码库',
|
||||
txt_master_password: '主密码',
|
||||
txt_email: '邮箱',
|
||||
txt_name: '名称',
|
||||
@@ -443,7 +446,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_loading: '加载中...',
|
||||
txt_loading_nodewarden: '正在加载 NodeWarden...',
|
||||
txt_search_sends: '搜索发送...',
|
||||
txt_search_your_secure_vault: '搜索你的保险库...',
|
||||
txt_search_your_secure_vault: '搜索你的密码库...',
|
||||
txt_refresh: '刷新',
|
||||
txt_sync: '同步',
|
||||
txt_sync_vault: '同步',
|
||||
@@ -752,7 +755,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_use_your_one_time_recovery_code_to_disable_two_step_verification: '使用一次性恢复代码禁用两步验证。',
|
||||
txt_user_deleted: '用户已删除',
|
||||
txt_user_status_updated: '用户状态已更新',
|
||||
txt_vault_synced: '保险库已同步',
|
||||
txt_vault_synced: '密码库已同步',
|
||||
txt_verify: '验证',
|
||||
txt_web: '网页',
|
||||
txt_windows_desktop: 'Windows 桌面端',
|
||||
@@ -782,6 +785,9 @@ const zhCNOverrides: Record<string, string> = {
|
||||
};
|
||||
|
||||
zhCNOverrides.txt_lock = '锁定';
|
||||
zhCNOverrides.txt_menu = '菜单';
|
||||
zhCNOverrides.txt_settings = '设置';
|
||||
zhCNOverrides.txt_back = '返回';
|
||||
zhCNOverrides.txt_passkey = 'Passkey';
|
||||
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
||||
zhCNOverrides.txt_attachments = '附件';
|
||||
@@ -901,8 +907,8 @@ zhCNOverrides.txt_folder_not_found = '文件夹不存在';
|
||||
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
|
||||
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
|
||||
zhCNOverrides.txt_other = '其他';
|
||||
zhCNOverrides.txt_vault_key_unavailable = '账户密钥不可用,请先解锁保险库后重试。';
|
||||
zhCNOverrides.txt_vault_not_ready = '保险库数据尚未就绪';
|
||||
zhCNOverrides.txt_vault_key_unavailable = '账户密钥不可用,请先解锁密码库后重试。';
|
||||
zhCNOverrides.txt_vault_not_ready = '密码库数据尚未就绪';
|
||||
zhCNOverrides.txt_unsupported_export_format = '不支持的导出格式';
|
||||
zhCNOverrides.txt_invalid_encrypted_export = '加密导出文件无效。';
|
||||
zhCNOverrides.txt_export_belongs_to_another_account = '此加密导出文件属于另一个账号。';
|
||||
|
||||
+651
-1
@@ -406,6 +406,23 @@ input[type='file'].input::file-selector-button:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.mobile-page-title {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
max-width: min(58vw, 240px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 57px;
|
||||
height: 57px;
|
||||
@@ -418,6 +435,18 @@ input[type='file'].input::file-selector-button:hover {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-lock-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-actions .btn {
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
@@ -509,6 +538,17 @@ input[type='file'].input::file-selector-button:hover {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mobile-sidebar-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
z-index: 54;
|
||||
}
|
||||
|
||||
.mobile-sidebar-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vault-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(420px, 46%) minmax(575px, 1fr);
|
||||
@@ -666,7 +706,7 @@ input[type='file'].input::file-selector-button:hover {
|
||||
|
||||
.toolbar .btn.small {
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -687,6 +727,10 @@ input[type='file'].input::file-selector-button:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-icon-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-menu-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
@@ -697,6 +741,7 @@ input[type='file'].input::file-selector-button:hover {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sort-trigger.active {
|
||||
@@ -854,6 +899,10 @@ input[type='file'].input::file-selector-button:hover {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobile-panel-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
@@ -1054,6 +1103,7 @@ input[type='file'].input::file-selector-button:hover {
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.value-ellipsis {
|
||||
@@ -1474,6 +1524,10 @@ input[type='file'].input::file-selector-button:hover {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table td::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: #667085;
|
||||
}
|
||||
@@ -1815,3 +1869,599 @@ input[type='file'].input::file-selector-button:hover {
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.auth-page {
|
||||
padding: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.standalone-shell {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.standalone-brand-outside {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.standalone-brand-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.standalone-brand-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.standalone-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 20px 16px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
height: 48px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
padding: 0;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
--mobile-topbar-height: 58px;
|
||||
--mobile-tabbar-height: 70px;
|
||||
height: 100dvh;
|
||||
max-width: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--mobile-topbar-height);
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-page-title {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.topbar-actions .user-chip,
|
||||
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle,
|
||||
.mobile-lock-btn {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle .btn-icon,
|
||||
.mobile-lock-btn .btn-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-side {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px 10px calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--mobile-tabbar-height);
|
||||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #d9e0ea;
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.mobile-tab {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 6px 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mobile-tab.active {
|
||||
color: #175ddc;
|
||||
background: #e8f0ff;
|
||||
}
|
||||
|
||||
.vault-grid {
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet.open {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: calc(var(--mobile-topbar-height) + 10px);
|
||||
bottom: auto;
|
||||
max-height: calc(100dvh - 145px);
|
||||
z-index: 55;
|
||||
overflow: auto;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.mobile-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mobile-sidebar-close {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #d7dde6;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .sidebar-block {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .tree-btn {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .folder-row {
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .folder-row .tree-btn {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .sidebar-title,
|
||||
.mobile-sidebar-sheet .sidebar-title-row {
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .tree-btn {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .tree-btn.active {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .folder-delete-btn {
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.list-col {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.list-icon-btn {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.list-icon-btn .btn-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toolbar.actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar.actions::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar.actions .btn.small {
|
||||
height: 34px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mobile-fab-wrap {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.mobile-fab-trigger {
|
||||
width: 36px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.mobile-fab-trigger .btn-icon {
|
||||
margin: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mobile-fab-wrap .create-menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
top: auto;
|
||||
bottom: calc(100% + 10px);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
padding: 6px;
|
||||
border-radius: 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.row-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.vault-grid.mobile-panel-detail .sidebar,
|
||||
.vault-grid.mobile-panel-detail .list-col,
|
||||
.vault-grid.mobile-panel-edit .sidebar,
|
||||
.vault-grid.mobile-panel-edit .list-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-detail-sheet {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-detail-sheet.open {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: var(--mobile-topbar-height);
|
||||
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
|
||||
z-index: 35;
|
||||
overflow: auto;
|
||||
background: #f5f7fb;
|
||||
padding: 10px 10px 18px;
|
||||
}
|
||||
|
||||
.mobile-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mobile-panel-back {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.detail-col .card,
|
||||
.import-export-panel,
|
||||
.backup-panel,
|
||||
.settings-subcard {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-actions .actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-actions .actions .btn,
|
||||
.detail-delete-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
grid-template-columns: minmax(64px, 80px) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kv-line {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.kv-actions {
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.kv-actions .btn.small {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.kv-actions .btn.small .btn-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.import-export-panels,
|
||||
.settings-twofactor-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-export-panel .actions .btn,
|
||||
.backup-panel .actions .btn,
|
||||
.settings-subcard .actions .btn,
|
||||
.section-head .actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totp-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.totp-qr {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.totp-qr svg,
|
||||
.totp-qr img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.invite-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mobile-settings-card {
|
||||
min-height: calc(100dvh - 145px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-settings-subhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-settings-back {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.mobile-settings-links {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.mobile-settings-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 46px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobile-settings-link.active {
|
||||
background: #e8f0ff;
|
||||
border-color: #b9cff6;
|
||||
color: #175ddc;
|
||||
}
|
||||
|
||||
.mobile-settings-logout {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stack,
|
||||
.import-export-page,
|
||||
.totp-codes-page,
|
||||
.detail-col {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.invite-create-group {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input.small {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table,
|
||||
.table tbody,
|
||||
.table tr,
|
||||
.table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.table td:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.table td::before {
|
||||
display: block;
|
||||
content: attr(data-label);
|
||||
margin-bottom: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 90%;
|
||||
max-width: 460px;
|
||||
max-height: calc(100dvh - 10px);
|
||||
overflow: auto;
|
||||
border-radius: 22px;
|
||||
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
height: 46px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user