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
+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>