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:
shuaiplus
2026-03-08 17:07:21 +08:00
parent 0e1152a0b9
commit 68583821fe
7 changed files with 1139 additions and 153 deletions
+279 -116
View File
@@ -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>
+9 -9
View File
@@ -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"
+96 -8
View File
@@ -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>
+86 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}