From 68583821fedc78b9237b5596e2f44c3e188f7f1d Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 8 Mar 2026 17:07:21 +0800 Subject: [PATCH] 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. --- webapp/src/App.tsx | 395 +++++++---- webapp/src/components/AdminPage.tsx | 18 +- webapp/src/components/SecurityDevicesPage.tsx | 12 +- webapp/src/components/SendsPage.tsx | 104 ++- webapp/src/components/VaultPage.tsx | 93 ++- webapp/src/lib/i18n.ts | 18 +- webapp/src/styles.css | 652 +++++++++++++++++- 7 files changed, 1139 insertions(+), 153 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 89121d2..3d46505 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -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([]); + const [mobileLayout, setMobileLayout] = useState(false); const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); const migratedPlainFolderIdsRef = useRef>(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 ; } @@ -1709,7 +1752,8 @@ export default function App() {
NodeWarden logo - NodeWarden + NodeWarden + {currentPageTitle}
@@ -1719,6 +1763,20 @@ export default function App() { + {showSidebarToggle && ( + + )} + @@ -1745,7 +1803,7 @@ export default function App() { {t('nav_admin_panel')} )} - + {t('nav_account_settings')} @@ -1800,127 +1858,203 @@ export default function App() { onDownloadAttachment={downloadVaultAttachment} /> + + {profile && ( +
+ {mobileLayout && ( +
+ +
+ )} + { + await enableTotpAction(secret, token); + await totpStatusQuery.refetch(); + }} + onOpenDisableTotp={() => setDisableTotpOpen(true)} + onGetRecoveryCode={getRecoveryCodeAction} + onNotify={pushToast} + /> +
+ )} +
{profile && ( - { - await enableTotpAction(secret, token); - await totpStatusQuery.refetch(); - }} - onOpenDisableTotp={() => setDisableTotpOpen(true)} - onGetRecoveryCode={getRecoveryCodeAction} - onNotify={pushToast} - /> +
+
+ + + {t('nav_account_settings')} + + + + {t('nav_device_management')} + + + + {t('nav_import_export')} + + {profile.role === 'admin' && ( + + + {t('nav_admin_panel')} + + )} + {profile.role === 'admin' && ( + + + {t('nav_backup_strategy')} + + )} +
+ +
)}
- 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(); - }, - }); - }} - /> +
+ {mobileLayout && ( +
+ +
+ )} + 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(); + }, + }); + }} + /> +
- { - 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')); - }} - /> +
+ {mobileLayout && ( +
+ +
+ )} + { + 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')); + }} + /> +
- +
+ {mobileLayout && ( +
+ +
+ )} + +
{profile?.role === 'admin' ? ( - +
+ {mobileLayout && ( +
+ +
+ )} + +
) : null}
+ +
diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx index d19dc89..dc78a0e 100644 --- a/webapp/src/components/AdminPage.tsx +++ b/webapp/src/components/AdminPage.tsx @@ -56,11 +56,11 @@ export default function AdminPage(props: AdminPageProps) { {props.users.map((user) => ( - {user.email} - {user.name || t('txt_dash')} - {roleText(user.role)} - {statusText(user.status)} - + {user.email} + {user.name || t('txt_dash')} + {roleText(user.role)} + {statusText(user.status)} +
+
+ )}
{t('txt_all_sends')}
@@ -235,16 +284,20 @@ export default function SendsPage(props: SendsPageProps) { )}
@@ -269,6 +322,8 @@ export default function SendsPage(props: SendsPageProps) { setIsEditing(false); setIsCreating(false); setDraft(null); + if (isMobileLayout) setMobilePanel('detail'); + setMobileSidebarOpen(false); }} >
@@ -289,7 +344,29 @@ export default function SendsPage(props: SendsPageProps) {
-
+
+ {isMobileLayout && mobilePanel !== 'list' && ( +
+ +
+ )} {isEditing && draft && (

{isCreating ? t('txt_new_send') : t('txt_edit_send')}

@@ -369,7 +446,18 @@ export default function SendsPage(props: SendsPageProps) { -
diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index d5344a9..4935e3d 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -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(null); + const [isMobileLayout, setIsMobileLayout] = useState(false); + const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list'); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const createMenuRef = useRef(null); const sortMenuRef = useRef(null); const attachmentInputRef = useRef(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): 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 ( <> -
-
-
+
+ {isMobileLayout && mobilePanel !== 'list' && ( +
+ +
+ )} {isEditing && draft && ( <>
diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 46f9384..26a323a 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -218,6 +218,9 @@ const messages: Record> = { 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> = { }; const zhCNOverrides: Record = { - nav_my_vault: '我的保险库', + nav_my_vault: '我的密码库', nav_sends: 'Send', nav_admin_panel: '用户管理', nav_account_settings: '账户设置', @@ -429,7 +432,7 @@ const zhCNOverrides: Record = { 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 = { 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 = { 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 = { }; 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 = '此加密导出文件属于另一个账号。'; diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 9a6e81a..403ac86 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -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; + } +}