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 { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 AuthViews from '@/components/AuthViews';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import ToastHost from '@/components/ToastHost';
|
import ToastHost from '@/components/ToastHost';
|
||||||
@@ -99,6 +99,8 @@ const SEND_KEY_SALT = 'bitwarden-send';
|
|||||||
const SEND_KEY_PURPOSE = 'send';
|
const SEND_KEY_PURPOSE = 'send';
|
||||||
const IMPORT_ROUTE = '/help/import-export';
|
const IMPORT_ROUTE = '/help/import-export';
|
||||||
const IMPORT_ROUTE_ALIASES = new Set(['/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/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 {
|
function looksLikeCipherString(value: string): boolean {
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
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);
|
} | null>(null);
|
||||||
|
|
||||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
|
const [mobileLayout, setMobileLayout] = useState(false);
|
||||||
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
|
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
|
||||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||||
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
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) {
|
function setSession(next: SessionState | null) {
|
||||||
setSessionState(next);
|
setSessionState(next);
|
||||||
saveSession(next);
|
saveSession(next);
|
||||||
@@ -1581,6 +1597,27 @@ export default function App() {
|
|||||||
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
||||||
const isPublicSendRoute = !!publicSendMatch;
|
const isPublicSendRoute = !!publicSendMatch;
|
||||||
const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location);
|
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(() => {
|
useEffect(() => {
|
||||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||||
@@ -1598,6 +1635,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [phase, profile?.role, location, navigate]);
|
}, [phase, profile?.role, location, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) {
|
||||||
|
navigate(SETTINGS_ACCOUNT_ROUTE);
|
||||||
|
}
|
||||||
|
}, [phase, mobileLayout, location, navigate]);
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
||||||
}
|
}
|
||||||
@@ -1709,7 +1752,8 @@ export default function App() {
|
|||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
<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>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<div className="user-chip">
|
<div className="user-chip">
|
||||||
@@ -1719,6 +1763,20 @@ export default function App() {
|
|||||||
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
||||||
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||||
</button>
|
</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}>
|
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
||||||
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1745,7 +1803,7 @@ export default function App() {
|
|||||||
<span>{t('nav_admin_panel')}</span>
|
<span>{t('nav_admin_panel')}</span>
|
||||||
</Link>
|
</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} />
|
<SettingsIcon size={16} />
|
||||||
<span>{t('nav_account_settings')}</span>
|
<span>{t('nav_account_settings')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1800,127 +1858,203 @@ export default function App() {
|
|||||||
onDownloadAttachment={downloadVaultAttachment}
|
onDownloadAttachment={downloadVaultAttachment}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</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">
|
<Route path="/settings">
|
||||||
{profile && (
|
{profile && (
|
||||||
<SettingsPage
|
<section className="card mobile-settings-card">
|
||||||
profile={profile}
|
<div className="mobile-settings-links">
|
||||||
totpEnabled={!!totpStatusQuery.data?.enabled}
|
<Link href={SETTINGS_ACCOUNT_ROUTE} className="mobile-settings-link">
|
||||||
onChangePassword={changePasswordAction}
|
<SettingsIcon size={18} />
|
||||||
onEnableTotp={async (secret, token) => {
|
<span>{t('nav_account_settings')}</span>
|
||||||
await enableTotpAction(secret, token);
|
</Link>
|
||||||
await totpStatusQuery.refetch();
|
<Link href="/security/devices" className="mobile-settings-link">
|
||||||
}}
|
<Shield size={18} />
|
||||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
<span>{t('nav_device_management')}</span>
|
||||||
onGetRecoveryCode={getRecoveryCodeAction}
|
</Link>
|
||||||
onNotify={pushToast}
|
<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>
|
||||||
<Route path="/security/devices">
|
<Route path="/security/devices">
|
||||||
<SecurityDevicesPage
|
<div className="stack">
|
||||||
devices={authorizedDevicesQuery.data || []}
|
{mobileLayout && (
|
||||||
loading={authorizedDevicesQuery.isFetching}
|
<div className="mobile-settings-subhead">
|
||||||
onRefresh={() => void refreshAuthorizedDevices()}
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||||
onRevokeTrust={(device) => {
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
setConfirm({
|
{t('txt_back')}
|
||||||
title: t('txt_revoke_device_authorization'),
|
</button>
|
||||||
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
</div>
|
||||||
danger: true,
|
)}
|
||||||
onConfirm: () => {
|
<SecurityDevicesPage
|
||||||
setConfirm(null);
|
devices={authorizedDevicesQuery.data || []}
|
||||||
void revokeDeviceTrustAction(device);
|
loading={authorizedDevicesQuery.isFetching}
|
||||||
},
|
onRefresh={() => void refreshAuthorizedDevices()}
|
||||||
});
|
onRevokeTrust={(device) => {
|
||||||
}}
|
setConfirm({
|
||||||
onRemoveDevice={(device) => {
|
title: t('txt_revoke_device_authorization'),
|
||||||
setConfirm({
|
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
||||||
title: t('txt_remove_device'),
|
danger: true,
|
||||||
message: t('txt_remove_device_name_and_clear_its_2fa_trust', { name: device.name }),
|
onConfirm: () => {
|
||||||
danger: true,
|
setConfirm(null);
|
||||||
onConfirm: () => {
|
void revokeDeviceTrustAction(device);
|
||||||
setConfirm(null);
|
},
|
||||||
void removeDeviceAction(device);
|
});
|
||||||
},
|
}}
|
||||||
});
|
onRemoveDevice={(device) => {
|
||||||
}}
|
setConfirm({
|
||||||
onRevokeAll={() => {
|
title: t('txt_remove_device'),
|
||||||
setConfirm({
|
message: t('txt_remove_device_name_and_clear_its_2fa_trust', { name: device.name }),
|
||||||
title: t('txt_revoke_all_trusted_devices'),
|
danger: true,
|
||||||
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
onConfirm: () => {
|
||||||
danger: true,
|
setConfirm(null);
|
||||||
onConfirm: () => {
|
void removeDeviceAction(device);
|
||||||
setConfirm(null);
|
},
|
||||||
void revokeAllDeviceTrustAction();
|
});
|
||||||
},
|
}}
|
||||||
});
|
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>
|
||||||
<Route path="/admin">
|
<Route path="/admin">
|
||||||
<AdminPage
|
<div className="stack">
|
||||||
currentUserId={profile?.id || ''}
|
{mobileLayout && (
|
||||||
users={usersQuery.data || []}
|
<div className="mobile-settings-subhead">
|
||||||
invites={invitesQuery.data || []}
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||||
onRefresh={() => {
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
void usersQuery.refetch();
|
{t('txt_back')}
|
||||||
void invitesQuery.refetch();
|
</button>
|
||||||
}}
|
</div>
|
||||||
onCreateInvite={async (hours) => {
|
)}
|
||||||
await createInvite(authedFetch, hours);
|
<AdminPage
|
||||||
await invitesQuery.refetch();
|
currentUserId={profile?.id || ''}
|
||||||
pushToast('success', t('txt_invite_created'));
|
users={usersQuery.data || []}
|
||||||
}}
|
invites={invitesQuery.data || []}
|
||||||
onDeleteAllInvites={async () => {
|
onRefresh={() => {
|
||||||
setConfirm({
|
void usersQuery.refetch();
|
||||||
title: t('txt_delete_all_invites'),
|
void invitesQuery.refetch();
|
||||||
message: t('txt_delete_all_invite_codes_active_inactive'),
|
}}
|
||||||
danger: true,
|
onCreateInvite={async (hours) => {
|
||||||
onConfirm: () => {
|
await createInvite(authedFetch, hours);
|
||||||
setConfirm(null);
|
await invitesQuery.refetch();
|
||||||
void (async () => {
|
pushToast('success', t('txt_invite_created'));
|
||||||
await deleteAllInvites(authedFetch);
|
}}
|
||||||
await invitesQuery.refetch();
|
onDeleteAllInvites={async () => {
|
||||||
pushToast('success', t('txt_all_invites_deleted'));
|
setConfirm({
|
||||||
})();
|
title: t('txt_delete_all_invites'),
|
||||||
},
|
message: t('txt_delete_all_invite_codes_active_inactive'),
|
||||||
});
|
danger: true,
|
||||||
}}
|
onConfirm: () => {
|
||||||
onToggleUserStatus={async (userId, status) => {
|
setConfirm(null);
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
void (async () => {
|
||||||
await usersQuery.refetch();
|
await deleteAllInvites(authedFetch);
|
||||||
pushToast('success', t('txt_user_status_updated'));
|
await invitesQuery.refetch();
|
||||||
}}
|
pushToast('success', t('txt_all_invites_deleted'));
|
||||||
onDeleteUser={async (userId) => {
|
})();
|
||||||
setConfirm({
|
},
|
||||||
title: t('txt_delete_user'),
|
});
|
||||||
message: t('txt_delete_this_user_and_all_user_data'),
|
}}
|
||||||
danger: true,
|
onToggleUserStatus={async (userId, status) => {
|
||||||
onConfirm: () => {
|
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||||
setConfirm(null);
|
await usersQuery.refetch();
|
||||||
void (async () => {
|
pushToast('success', t('txt_user_status_updated'));
|
||||||
await deleteUser(authedFetch, userId);
|
}}
|
||||||
await usersQuery.refetch();
|
onDeleteUser={async (userId) => {
|
||||||
pushToast('success', t('txt_user_deleted'));
|
setConfirm({
|
||||||
})();
|
title: t('txt_delete_user'),
|
||||||
},
|
message: t('txt_delete_this_user_and_all_user_data'),
|
||||||
});
|
danger: true,
|
||||||
}}
|
onConfirm: () => {
|
||||||
onRevokeInvite={async (code) => {
|
setConfirm(null);
|
||||||
await revokeInvite(authedFetch, code);
|
void (async () => {
|
||||||
await invitesQuery.refetch();
|
await deleteUser(authedFetch, userId);
|
||||||
pushToast('success', t('txt_invite_revoked'));
|
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>
|
||||||
<Route path={IMPORT_ROUTE}>
|
<Route path={IMPORT_ROUTE}>
|
||||||
<ImportPage
|
<div className="stack">
|
||||||
onImport={handleImportAction}
|
{mobileLayout && (
|
||||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
<div className="mobile-settings-subhead">
|
||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||||
onNotify={pushToast}
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
folders={decryptedFolders}
|
{t('txt_back')}
|
||||||
onExport={handleExportAction}
|
</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>
|
||||||
<Route path="/tools/import">
|
<Route path="/tools/import">
|
||||||
<ImportPage
|
<ImportPage
|
||||||
@@ -1974,12 +2108,41 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/help">
|
<Route path="/help">
|
||||||
{profile?.role === 'admin' ? (
|
{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}
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{props.users.map((user) => (
|
{props.users.map((user) => (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td>{user.email}</td>
|
<td data-label={t('txt_email')}>{user.email}</td>
|
||||||
<td>{user.name || t('txt_dash')}</td>
|
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||||
<td>{roleText(user.role)}</td>
|
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||||
<td>{statusText(user.status)}</td>
|
<td data-label={t('txt_status')}>{statusText(user.status)}</td>
|
||||||
<td>
|
<td data-label={t('txt_actions')}>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -126,10 +126,10 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{pagedInvites.map((invite) => (
|
{pagedInvites.map((invite) => (
|
||||||
<tr key={invite.code}>
|
<tr key={invite.code}>
|
||||||
<td>{invite.code}</td>
|
<td data-label={t('txt_code')}>{invite.code}</td>
|
||||||
<td>{statusText(invite.status)}</td>
|
<td data-label={t('txt_status')}>{statusText(invite.status)}</td>
|
||||||
<td>{formatExpiresAt(invite.expiresAt)}</td>
|
<td data-label={t('txt_expires_at')}>{formatExpiresAt(invite.expiresAt)}</td>
|
||||||
<td>
|
<td data-label={t('txt_actions')}>
|
||||||
<div className="actions invite-row-actions">
|
<div className="actions invite-row-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -79,14 +79,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{props.devices.map((device) => (
|
{props.devices.map((device) => (
|
||||||
<tr key={device.identifier}>
|
<tr key={device.identifier}>
|
||||||
<td>
|
<td data-label={t('txt_device')}>
|
||||||
<div>{device.name || t('txt_unknown_device')}</div>
|
<div>{device.name || t('txt_unknown_device')}</div>
|
||||||
<div className="muted-inline">{device.identifier}</div>
|
<div className="muted-inline">{device.identifier}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{mapDeviceTypeName(device.type)}</td>
|
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||||
<td>{formatDateTime(device.creationDate)}</td>
|
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
||||||
<td>{formatDateTime(device.revisionDate)}</td>
|
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
|
||||||
<td>
|
<td data-label={t('txt_trusted_until')}>
|
||||||
{device.trusted ? (
|
{device.trusted ? (
|
||||||
<div className="trusted-cell">
|
<div className="trusted-cell">
|
||||||
<Clock3 size={13} />
|
<Clock3 size={13} />
|
||||||
@@ -96,7 +96,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<span className="muted-inline">{t('txt_not_trusted')}</span>
|
<span className="muted-inline">{t('txt_not_trusted')}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label={t('txt_actions')}>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
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 type { Send, SendDraft } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ interface SendsPageProps {
|
|||||||
|
|
||||||
type SendTypeFilter = 'all' | 'text' | 'file';
|
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||||
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
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 {
|
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||||
if (!iso) return String(fallback);
|
if (!iso) return String(fallback);
|
||||||
@@ -67,6 +68,9 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
const [draft, setDraft] = useState<SendDraft | null>(null);
|
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
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>(() => {
|
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
|
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
|
||||||
@@ -83,6 +108,19 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
}
|
}
|
||||||
}, [autoCopyLink]);
|
}, [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 filteredSends = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
return props.sends.filter((send) => {
|
return props.sends.filter((send) => {
|
||||||
@@ -141,6 +179,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -153,6 +192,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
if (selectedId === send.id) setSelectedId(null);
|
if (selectedId === send.id) setSelectedId(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('list');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -176,8 +216,17 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vault-grid">
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
<aside className="sidebar">
|
{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-block">
|
||||||
<div className="sidebar-title">{t('txt_all_sends')}</div>
|
<div className="sidebar-title">{t('txt_all_sends')}</div>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
||||||
@@ -206,7 +255,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
value={search}
|
value={search}
|
||||||
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
|
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')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,16 +284,20 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary small"
|
className="btn btn-primary small mobile-fab-trigger"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
aria-label={t('txt_add')}
|
||||||
|
title={t('txt_add')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setDraft(buildDefaultDraft());
|
setDraft(buildDefaultDraft());
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={14} className="btn-icon" /> {t('txt_add')}
|
<Plus size={14} className="btn-icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-panel">
|
<div className="list-panel">
|
||||||
@@ -269,6 +322,8 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="list-icon-wrap">
|
<div className="list-icon-wrap">
|
||||||
@@ -289,7 +344,29 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 && (
|
{isEditing && draft && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
<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()}>
|
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||||
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||||
</button>
|
</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')}
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
ChevronLeft,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Download,
|
Download,
|
||||||
@@ -76,6 +77,7 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
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 }> = [
|
const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
{ value: 'created', label: t('txt_sort_created') },
|
{ value: 'created', label: t('txt_sort_created') },
|
||||||
@@ -398,12 +400,36 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
const [repromptPassword, setRepromptPassword] = useState('');
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
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 createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const sortMenuRef = useRef<HTMLDivElement | null>(null);
|
const sortMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const sshSeedTicketRef = useRef(0);
|
const sshSeedTicketRef = useRef(0);
|
||||||
const sshFingerprintTicketRef = 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(() => {
|
useEffect(() => {
|
||||||
const onQuickAdd = () => {
|
const onQuickAdd = () => {
|
||||||
startCreate(1);
|
startCreate(1);
|
||||||
@@ -475,6 +501,19 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
setRepromptOpen(false);
|
setRepromptOpen(false);
|
||||||
}, [selectedCipherId]);
|
}, [selectedCipherId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobileLayout) {
|
||||||
|
setMobilePanel('list');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEditing) {
|
||||||
|
setMobilePanel('edit');
|
||||||
|
} else if (!selectedCipherId) {
|
||||||
|
setMobilePanel('list');
|
||||||
|
}
|
||||||
|
}, [isMobileLayout, isEditing, selectedCipherId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchComposing) return;
|
if (searchComposing) return;
|
||||||
const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90);
|
const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90);
|
||||||
@@ -613,6 +652,8 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
if (type === 5) void seedSshDefaults();
|
if (type === 5) void seedSshDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,15 +666,19 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit(): void {
|
function cancelEdit(): void {
|
||||||
|
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
|
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDraft(patch: Partial<VaultDraft>): void {
|
function updateDraft(patch: Partial<VaultDraft>): void {
|
||||||
@@ -755,6 +800,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -767,6 +813,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
await props.onDelete(pendingDelete);
|
await props.onDelete(pendingDelete);
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
|
if (isMobileLayout) setMobilePanel('list');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -862,8 +909,17 @@ function folderName(id: string | null | undefined): string {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="vault-grid">
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
<aside className="sidebar">
|
{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">
|
<div className="sidebar-block">
|
||||||
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'all' })}>
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -998,9 +1054,15 @@ function folderName(id: string | null | undefined): string {
|
|||||||
>
|
>
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
</button>
|
</button>
|
||||||
<div className="create-menu-wrap" ref={createMenuRef}>
|
<div className="create-menu-wrap mobile-fab-wrap" ref={createMenuRef}>
|
||||||
<button type="button" className="btn btn-primary small" onClick={() => setCreateMenuOpen((x) => !x)}>
|
<button
|
||||||
<Plus size={14} className="btn-icon" /> {t('txt_add')}
|
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>
|
</button>
|
||||||
{createMenuOpen && (
|
{createMenuOpen && (
|
||||||
<div className="create-menu">
|
<div className="create-menu">
|
||||||
@@ -1056,6 +1118,8 @@ function folderName(id: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
setSelectedCipherId(cipher.id);
|
setSelectedCipherId(cipher.id);
|
||||||
setRepromptApprovedCipherId(null);
|
setRepromptApprovedCipherId(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="list-icon-wrap">
|
<div className="list-icon-wrap">
|
||||||
@@ -1072,7 +1136,22 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 && (
|
{isEditing && draft && (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
+12
-6
@@ -218,6 +218,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_log_in: "Log In",
|
txt_log_in: "Log In",
|
||||||
txt_log_out: "Log Out",
|
txt_log_out: "Log Out",
|
||||||
txt_lock: "Lock",
|
txt_lock: "Lock",
|
||||||
|
txt_menu: "Menu",
|
||||||
|
txt_settings: "Settings",
|
||||||
|
txt_back: "Back",
|
||||||
txt_login: "Login",
|
txt_login: "Login",
|
||||||
txt_login_credentials: "Login Credentials",
|
txt_login_credentials: "Login Credentials",
|
||||||
txt_login_failed: "Login failed",
|
txt_login_failed: "Login failed",
|
||||||
@@ -395,7 +398,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const zhCNOverrides: Record<string, string> = {
|
const zhCNOverrides: Record<string, string> = {
|
||||||
nav_my_vault: '我的保险库',
|
nav_my_vault: '我的密码库',
|
||||||
nav_sends: 'Send',
|
nav_sends: 'Send',
|
||||||
nav_admin_panel: '用户管理',
|
nav_admin_panel: '用户管理',
|
||||||
nav_account_settings: '账户设置',
|
nav_account_settings: '账户设置',
|
||||||
@@ -429,7 +432,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_create_account: '创建账户',
|
txt_create_account: '创建账户',
|
||||||
txt_back_to_login: '返回登录',
|
txt_back_to_login: '返回登录',
|
||||||
txt_unlock: '解锁',
|
txt_unlock: '解锁',
|
||||||
txt_unlock_vault: '解锁保险库',
|
txt_unlock_vault: '解锁密码库',
|
||||||
txt_master_password: '主密码',
|
txt_master_password: '主密码',
|
||||||
txt_email: '邮箱',
|
txt_email: '邮箱',
|
||||||
txt_name: '名称',
|
txt_name: '名称',
|
||||||
@@ -443,7 +446,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_loading: '加载中...',
|
txt_loading: '加载中...',
|
||||||
txt_loading_nodewarden: '正在加载 NodeWarden...',
|
txt_loading_nodewarden: '正在加载 NodeWarden...',
|
||||||
txt_search_sends: '搜索发送...',
|
txt_search_sends: '搜索发送...',
|
||||||
txt_search_your_secure_vault: '搜索你的保险库...',
|
txt_search_your_secure_vault: '搜索你的密码库...',
|
||||||
txt_refresh: '刷新',
|
txt_refresh: '刷新',
|
||||||
txt_sync: '同步',
|
txt_sync: '同步',
|
||||||
txt_sync_vault: '同步',
|
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_use_your_one_time_recovery_code_to_disable_two_step_verification: '使用一次性恢复代码禁用两步验证。',
|
||||||
txt_user_deleted: '用户已删除',
|
txt_user_deleted: '用户已删除',
|
||||||
txt_user_status_updated: '用户状态已更新',
|
txt_user_status_updated: '用户状态已更新',
|
||||||
txt_vault_synced: '保险库已同步',
|
txt_vault_synced: '密码库已同步',
|
||||||
txt_verify: '验证',
|
txt_verify: '验证',
|
||||||
txt_web: '网页',
|
txt_web: '网页',
|
||||||
txt_windows_desktop: 'Windows 桌面端',
|
txt_windows_desktop: 'Windows 桌面端',
|
||||||
@@ -782,6 +785,9 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
zhCNOverrides.txt_lock = '锁定';
|
zhCNOverrides.txt_lock = '锁定';
|
||||||
|
zhCNOverrides.txt_menu = '菜单';
|
||||||
|
zhCNOverrides.txt_settings = '设置';
|
||||||
|
zhCNOverrides.txt_back = '返回';
|
||||||
zhCNOverrides.txt_passkey = 'Passkey';
|
zhCNOverrides.txt_passkey = 'Passkey';
|
||||||
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
||||||
zhCNOverrides.txt_attachments = '附件';
|
zhCNOverrides.txt_attachments = '附件';
|
||||||
@@ -901,8 +907,8 @@ zhCNOverrides.txt_folder_not_found = '文件夹不存在';
|
|||||||
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
|
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
|
||||||
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
|
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
|
||||||
zhCNOverrides.txt_other = '其他';
|
zhCNOverrides.txt_other = '其他';
|
||||||
zhCNOverrides.txt_vault_key_unavailable = '账户密钥不可用,请先解锁保险库后重试。';
|
zhCNOverrides.txt_vault_key_unavailable = '账户密钥不可用,请先解锁密码库后重试。';
|
||||||
zhCNOverrides.txt_vault_not_ready = '保险库数据尚未就绪';
|
zhCNOverrides.txt_vault_not_ready = '密码库数据尚未就绪';
|
||||||
zhCNOverrides.txt_unsupported_export_format = '不支持的导出格式';
|
zhCNOverrides.txt_unsupported_export_format = '不支持的导出格式';
|
||||||
zhCNOverrides.txt_invalid_encrypted_export = '加密导出文件无效。';
|
zhCNOverrides.txt_invalid_encrypted_export = '加密导出文件无效。';
|
||||||
zhCNOverrides.txt_export_belongs_to_another_account = '此加密导出文件属于另一个账号。';
|
zhCNOverrides.txt_export_belongs_to_another_account = '此加密导出文件属于另一个账号。';
|
||||||
|
|||||||
+651
-1
@@ -406,6 +406,23 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
color: #1e293b;
|
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 {
|
.brand-logo {
|
||||||
width: 57px;
|
width: 57px;
|
||||||
height: 57px;
|
height: 57px;
|
||||||
@@ -418,6 +435,18 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-tabbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-lock-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-actions .btn {
|
.topbar-actions .btn {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -509,6 +538,17 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
overflow: auto;
|
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 {
|
.vault-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px minmax(420px, 46%) minmax(575px, 1fr);
|
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 {
|
.toolbar .btn.small {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 9px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +727,10 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-icon-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.sort-menu-wrap {
|
.sort-menu-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -697,6 +741,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-trigger.active {
|
.sort-trigger.active {
|
||||||
@@ -854,6 +899,10 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-panel-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1054,6 +1103,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-ellipsis {
|
.value-ellipsis {
|
||||||
@@ -1474,6 +1524,10 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table td::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.table th {
|
.table th {
|
||||||
color: #667085;
|
color: #667085;
|
||||||
}
|
}
|
||||||
@@ -1815,3 +1869,599 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
line-height: 1.4;
|
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