mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance mobile vault filter UI and improve styling for better usability
This commit is contained in:
+1
-1
@@ -1535,7 +1535,7 @@ export default function App() {
|
||||
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
||||
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
||||
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
||||
const showSidebarToggle = mobileLayout && location === '/sends';
|
||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||
const demoDomainRules = useMemo<DomainRules>(() => ({
|
||||
equivalentDomains: [
|
||||
|
||||
@@ -1115,6 +1115,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
busy={busy}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
folders={props.folders}
|
||||
searchInput={searchInput}
|
||||
sortMode={sortMode}
|
||||
sortMenuOpen={sortMenuOpen}
|
||||
@@ -1141,6 +1142,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
onToggleSortMenu={handleToggleSortMenu}
|
||||
onSelectSortMode={handleSelectSortMode}
|
||||
onDuplicateModeChange={setDuplicateMode}
|
||||
onChangeFilter={setSidebarFilter}
|
||||
onSyncVault={handleSyncVault}
|
||||
onOpenBulkDelete={handleOpenBulkDelete}
|
||||
onSelectDuplicates={handleSelectDuplicates}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import type { ComponentChildren, RefObject } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { memo } from 'preact/compat';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import {
|
||||
Archive,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Folder as FolderIcon,
|
||||
FolderInput,
|
||||
FolderX,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ShieldUser,
|
||||
Star,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-preact';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import type { Cipher, Folder } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CreateTypeIcon,
|
||||
@@ -27,6 +50,7 @@ interface VaultListPanelProps {
|
||||
busy: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
folders: Folder[];
|
||||
searchInput: string;
|
||||
sortMode: VaultSortMode;
|
||||
sortMenuOpen: boolean;
|
||||
@@ -53,6 +77,7 @@ interface VaultListPanelProps {
|
||||
onToggleSortMenu: () => void;
|
||||
onSelectSortMode: (value: VaultSortMode) => void;
|
||||
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
|
||||
onChangeFilter: (filter: SidebarFilter) => void;
|
||||
onSyncVault: () => void;
|
||||
onOpenBulkDelete: () => void;
|
||||
onSelectDuplicates: () => void;
|
||||
@@ -80,6 +105,16 @@ interface CipherListItemProps {
|
||||
onSelectCipher: (cipherId: string) => void;
|
||||
}
|
||||
|
||||
type MobileFilterMenuKey = 'duplicate' | 'menu' | 'type' | 'folder';
|
||||
|
||||
interface MobileFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: ComponentChildren;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
|
||||
return (
|
||||
@@ -115,14 +150,116 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
||||
});
|
||||
|
||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState<MobileFilterMenuKey | null>(null);
|
||||
const mobileFilterRef = useRef<HTMLDivElement | null>(null);
|
||||
const createTypeOptions = getCreateTypeOptions();
|
||||
const duplicateDetectionOptions = getDuplicateDetectionOptions();
|
||||
const vaultSortOptions = getVaultSortOptions();
|
||||
const createMenu = (
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||
const duplicateModeOptions: MobileFilterOption[] = duplicateDetectionOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
icon: option.value === 'login-site' ? <Globe size={14} /> : option.value === 'exact' ? <Copy size={14} /> : <KeyRound size={14} />,
|
||||
active: props.duplicateMode === option.value,
|
||||
onSelect: () => props.onDuplicateModeChange(option.value),
|
||||
}));
|
||||
const menuFilterOptions: MobileFilterOption[] = [
|
||||
{ value: 'all', label: t('txt_all_items'), icon: <LayoutGrid size={14} />, active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) },
|
||||
{ value: 'favorite', label: t('txt_favorites'), icon: <Star size={14} />, active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) },
|
||||
{ value: 'archive', label: t('txt_archive'), icon: <Archive size={14} />, active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) },
|
||||
{ value: 'trash', label: t('txt_trash'), icon: <Trash2 size={14} />, active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) },
|
||||
{ value: 'duplicates', label: t('txt_duplicates'), icon: <Copy size={14} />, active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) },
|
||||
];
|
||||
const typeMobileFilterOptions: MobileFilterOption[] = [
|
||||
{ value: 'login', label: t('txt_login'), icon: <Globe size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) },
|
||||
{ value: 'card', label: t('txt_card'), icon: <CreditCard size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) },
|
||||
{ value: 'identity', label: t('txt_identity'), icon: <ShieldUser size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) },
|
||||
{ value: 'note', label: t('txt_note'), icon: <StickyNote size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) },
|
||||
{ value: 'ssh', label: t('txt_ssh_key'), icon: <KeyRound size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'ssh' }) },
|
||||
];
|
||||
const folderMobileFilterOptions: MobileFilterOption[] = [
|
||||
{ value: '__none__', label: t('txt_no_folder'), icon: <FolderX size={14} />, active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: null }) },
|
||||
...props.folders.map((folder) => ({
|
||||
value: folder.id,
|
||||
label: folder.decName || folder.name || folder.id,
|
||||
icon: <FolderIcon size={14} />,
|
||||
active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id,
|
||||
onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: folder.id }),
|
||||
})),
|
||||
];
|
||||
const menuFilterSelected = menuFilterOptions.find((option) => option.active);
|
||||
const typeFilterSelected = typeMobileFilterOptions.find((option) => option.active);
|
||||
const folderFilterSelected = folderMobileFilterOptions.find((option) => option.active);
|
||||
const duplicateModeSelected = duplicateModeOptions.find((option) => option.active);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: Event) => {
|
||||
if (!mobileFilterOpen) return;
|
||||
const target = event.target as Node | null;
|
||||
if (mobileFilterRef.current && target && !mobileFilterRef.current.contains(target)) {
|
||||
setMobileFilterOpen(null);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMobileFilterOpen(null);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [mobileFilterOpen]);
|
||||
|
||||
const renderMobileFilterMenu = (
|
||||
key: MobileFilterMenuKey,
|
||||
label: string,
|
||||
selected: MobileFilterOption | undefined,
|
||||
fallbackIcon: ComponentChildren,
|
||||
options: MobileFilterOption[]
|
||||
) => (
|
||||
<div className="mobile-vault-filter-control">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small mobile-fab-trigger"
|
||||
className={`mobile-vault-filter-trigger ${mobileFilterOpen === key ? 'active' : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={mobileFilterOpen === key}
|
||||
onClick={() => setMobileFilterOpen((open) => open === key ? null : key)}
|
||||
>
|
||||
<span className="mobile-vault-filter-trigger-icon">{selected?.icon || fallbackIcon}</span>
|
||||
<span className="mobile-vault-filter-trigger-label">{selected?.label || label}</span>
|
||||
<ChevronDown size={13} className="mobile-vault-filter-chevron" />
|
||||
</button>
|
||||
{mobileFilterOpen === key && (
|
||||
<div className="sort-menu mobile-vault-filter-menu" role="menu">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`sort-menu-item mobile-vault-filter-item ${option.active ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
option.onSelect();
|
||||
setMobileFilterOpen(null);
|
||||
}}
|
||||
role="menuitemradio"
|
||||
aria-checked={option.active}
|
||||
>
|
||||
<span className="mobile-vault-filter-item-main">
|
||||
<span className="mobile-vault-filter-item-icon">{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
{option.active ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const createMenu = (
|
||||
<div className={`create-menu-wrap ${props.isMobileLayout ? 'mobile-fab-wrap' : 'desktop-create-menu-wrap'}`} ref={props.createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-primary small ${props.isMobileLayout ? 'mobile-fab-trigger' : 'desktop-create-trigger'}`}
|
||||
aria-label={t('txt_add')}
|
||||
title={t('txt_add')}
|
||||
onClick={props.onToggleCreateMenu}
|
||||
@@ -144,139 +281,127 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
|
||||
return (
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
<div className="search-input-wrap">
|
||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||
<select
|
||||
className="input duplicate-mode-select duplicate-mode-head-select"
|
||||
value={props.duplicateMode}
|
||||
aria-label={t('txt_duplicate_detection_mode')}
|
||||
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
|
||||
>
|
||||
{duplicateDetectionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="list-toolbar-stack" ref={mobileFilterRef}>
|
||||
<div className={`list-head ${props.selectedCount > 0 ? 'selection-mode' : ''}`}>
|
||||
{props.selectedCount > 0 ? (
|
||||
<>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_your_secure_vault')}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||
e.preventDefault();
|
||||
props.onClearSearch();
|
||||
}}
|
||||
/>
|
||||
{!!props.searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear-btn"
|
||||
aria-label={t('txt_clear_search')}
|
||||
title={t('txt_clear_search_esc')}
|
||||
onClick={props.onClearSearch}
|
||||
>
|
||||
<X size={14} />
|
||||
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
{props.sidebarFilter.kind === 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||
</button>
|
||||
)}
|
||||
{props.sidebarFilter.kind === 'archive' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||
</button>
|
||||
)}
|
||||
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||
</button>
|
||||
)}
|
||||
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-danger small" disabled={props.busy} onClick={props.onOpenBulkDelete}>
|
||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="search-input-wrap">
|
||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||
<div className="duplicate-mode-head-menu">
|
||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||
e.preventDefault();
|
||||
props.onClearSearch();
|
||||
}}
|
||||
/>
|
||||
{!!props.searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear-btn"
|
||||
aria-label={t('txt_clear_search')}
|
||||
title={t('txt_clear_search_esc')}
|
||||
onClick={props.onClearSearch}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||
<div className="duplicate-mode-head-menu">
|
||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||
</div>
|
||||
)}
|
||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary small sort-trigger sort-trigger-labeled ${props.sortMenuOpen ? 'active' : ''}`}
|
||||
aria-label={t('txt_sort')}
|
||||
title={t('txt_sort')}
|
||||
onClick={props.onToggleSortMenu}
|
||||
>
|
||||
<ArrowUpDown size={14} className="btn-icon" /> <span>{t('txt_sort')}</span>
|
||||
</button>
|
||||
{props.sortMenuOpen && (
|
||||
<div className="sort-menu">
|
||||
{vaultSortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectSortMode(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||
</button>
|
||||
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
|
||||
aria-label={t('txt_sort')}
|
||||
title={t('txt_sort')}
|
||||
onClick={props.onToggleSortMenu}
|
||||
>
|
||||
<ArrowUpDown size={14} className="btn-icon" />
|
||||
</button>
|
||||
{props.sortMenuOpen && (
|
||||
<div className="sort-menu">
|
||||
{vaultSortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectSortMode(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
|
||||
{t('txt_total_items_count', { count: props.totalCipherCount })}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
|
||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||
<select
|
||||
className="input duplicate-mode-select duplicate-mode-toolbar-select"
|
||||
value={props.duplicateMode}
|
||||
aria-label={t('txt_duplicate_detection_mode')}
|
||||
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
|
||||
>
|
||||
{duplicateDetectionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||
</button>
|
||||
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
)}
|
||||
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||
props.isMobileLayout && typeof document !== 'undefined'
|
||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
||||
: createMenu
|
||||
{props.isMobileLayout && (
|
||||
<div className="mobile-vault-filter-row" aria-label={t('txt_filter')}>
|
||||
{renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, <LayoutGrid size={14} />, menuFilterOptions)}
|
||||
{renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, <Globe size={14} />, typeMobileFilterOptions)}
|
||||
{renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, <FolderIcon size={14} />, folderMobileFilterOptions)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible
|
||||
? createPortal(createMenu, document.body)
|
||||
: null}
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
||||
|
||||
@@ -758,6 +758,7 @@ const en: Record<string, string> = {
|
||||
"txt_search_sends": "Search sends...",
|
||||
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
||||
"txt_search_your_secure_vault": "Search your secure vault...",
|
||||
"txt_search_items_count": "Search within {count} items...",
|
||||
"txt_clear_search": "Clear search",
|
||||
"txt_clear_search_esc": "Clear search (Esc)",
|
||||
"txt_sort": "Sort",
|
||||
|
||||
@@ -758,6 +758,7 @@ const es: Record<string, string> = {
|
||||
"txt_search_sends": "Buscar envíos...",
|
||||
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
||||
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
||||
"txt_search_items_count": "Buscar entre {count} elementos...",
|
||||
"txt_clear_search": "Limpiar búsqueda",
|
||||
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
||||
"txt_sort": "Ordenar",
|
||||
|
||||
@@ -758,6 +758,7 @@ const ru: Record<string, string> = {
|
||||
"txt_search_sends": "Поиск отправляет...",
|
||||
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
||||
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
||||
"txt_search_items_count": "Поиск по {count} элементам...",
|
||||
"txt_clear_search": "Очистить поиск",
|
||||
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
||||
"txt_sort": "Сортировать",
|
||||
|
||||
@@ -758,6 +758,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_search_sends": "搜索 Send...",
|
||||
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
||||
"txt_search_your_secure_vault": "搜索你的密码库...",
|
||||
"txt_search_items_count": "共 {count} 项中搜索...",
|
||||
"txt_clear_search": "清空搜索",
|
||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||
"txt_sort": "排序",
|
||||
|
||||
@@ -758,6 +758,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_search_sends": "搜索 Send...",
|
||||
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
||||
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
||||
"txt_search_items_count": "在共 {count} 項中搜索...",
|
||||
"txt_clear_search": "清空搜索",
|
||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||
"txt_sort": "排序",
|
||||
|
||||
@@ -596,7 +596,7 @@ h4 {
|
||||
}
|
||||
|
||||
.list-head {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.list-count {
|
||||
@@ -1069,6 +1069,7 @@ textarea {
|
||||
.list-panel {
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
:root[data-theme='dark'] .textarea,
|
||||
:root[data-theme='dark'] select.input,
|
||||
:root[data-theme='dark'] .search-input,
|
||||
:root[data-theme='dark'] .mobile-vault-filter-trigger,
|
||||
:root[data-theme='dark'] .dialog input,
|
||||
:root[data-theme='dark'] .dialog textarea,
|
||||
:root[data-theme='dark'] .dialog select {
|
||||
@@ -79,6 +80,13 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .mobile-vault-filter-trigger:hover,
|
||||
:root[data-theme='dark'] .mobile-vault-filter-trigger.active {
|
||||
background: var(--panel-muted);
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--line));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .input::placeholder,
|
||||
:root[data-theme='dark'] .textarea::placeholder,
|
||||
:root[data-theme='dark'] input::placeholder,
|
||||
|
||||
@@ -31,19 +31,12 @@
|
||||
}
|
||||
|
||||
select.input {
|
||||
@apply py-0 pr-[42px];
|
||||
@apply py-0 pr-3.5;
|
||||
line-height: 1.5;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #365fa8 50%),
|
||||
linear-gradient(135deg, #365fa8 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 3px),
|
||||
calc(100% - 12px) calc(50% - 3px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
appearance: auto;
|
||||
-webkit-appearance: auto;
|
||||
-moz-appearance: auto;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
input[type='file'].input {
|
||||
|
||||
@@ -302,10 +302,15 @@
|
||||
}
|
||||
|
||||
.list-head {
|
||||
@apply grid items-center gap-2;
|
||||
@apply grid items-center gap-1.5;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
}
|
||||
|
||||
.list-head.selection-mode {
|
||||
@apply justify-stretch;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.list-count {
|
||||
grid-column: auto;
|
||||
@apply w-auto whitespace-nowrap text-xs;
|
||||
@@ -316,15 +321,50 @@
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
@apply h-[42px] w-full min-w-0 rounded-[14px];
|
||||
@apply h-[34px] w-full min-w-0 rounded-[10px] px-3 py-0 text-[13px] font-semibold;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-row {
|
||||
@apply grid min-w-0 gap-1.5;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.list-head .duplicate-mode-head-select {
|
||||
@apply h-[34px] min-w-0 w-auto max-w-full rounded-full;
|
||||
@apply h-[34px] min-w-0 w-auto max-w-full rounded-[10px];
|
||||
}
|
||||
|
||||
.list-icon-btn {
|
||||
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
|
||||
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
||||
}
|
||||
|
||||
.list-head.selection-mode > .btn.small {
|
||||
@apply h-[34px] min-w-0 w-full justify-center gap-1 px-2 text-[12px];
|
||||
}
|
||||
|
||||
.list-head.selection-mode > .btn.small .btn-icon {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.sort-trigger {
|
||||
@apply h-[34px] w-[34px] min-w-[34px] rounded-[10px];
|
||||
}
|
||||
|
||||
.sort-trigger.sort-trigger-labeled {
|
||||
@apply h-[34px] w-[34px] min-w-[34px] gap-0 px-0 text-[0];
|
||||
}
|
||||
|
||||
.sort-trigger.sort-trigger-labeled .btn-icon {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.desktop-create-menu-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.duplicate-mode-head-menu .mobile-vault-filter-menu {
|
||||
min-width: max(100%, 190px);
|
||||
}
|
||||
|
||||
.toolbar.actions {
|
||||
@@ -346,6 +386,10 @@
|
||||
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
|
||||
}
|
||||
|
||||
.list-count-status {
|
||||
@apply mb-1 px-1;
|
||||
}
|
||||
|
||||
.mobile-fab-wrap {
|
||||
@apply fixed right-3.5 z-[45];
|
||||
bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom));
|
||||
|
||||
+118
-4
@@ -221,24 +221,110 @@ select.input.duplicate-mode-toolbar-select {
|
||||
@apply h-[34px] w-auto min-w-[156px] max-w-full;
|
||||
}
|
||||
|
||||
.duplicate-mode-head-menu {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.duplicate-mode-toolbar-select {
|
||||
@apply w-auto max-w-[170px] shrink-0;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
@apply mb-2 flex items-center gap-2.5;
|
||||
@apply mb-1.5 flex items-center gap-2;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.list-head .search-input-wrap {
|
||||
.list-head.selection-mode {
|
||||
@apply gap-2;
|
||||
}
|
||||
|
||||
.list-head.selection-mode > .btn.small {
|
||||
@apply min-w-0 flex-1 justify-center;
|
||||
}
|
||||
|
||||
.list-head .search-input-wrap,
|
||||
.duplicate-mode-head-menu {
|
||||
@apply min-w-0 flex-auto;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
@apply h-[42px];
|
||||
@apply h-[34px] rounded-[10px] px-3 py-0 text-[13px] font-semibold;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.list-head .btn {
|
||||
@apply whitespace-nowrap;
|
||||
@apply h-[34px] whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
||||
}
|
||||
|
||||
.mobile-vault-filter-row {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-control {
|
||||
@apply relative min-w-0;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-trigger {
|
||||
@apply flex h-[34px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-[10px] border px-2.5 py-0 text-left text-[13px] font-semibold;
|
||||
background: var(--panel);
|
||||
border-color: rgba(74, 103, 150, 0.28);
|
||||
color: var(--muted-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74);
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-smooth),
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth),
|
||||
box-shadow var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.mobile-vault-filter-trigger:hover,
|
||||
.mobile-vault-filter-trigger.active {
|
||||
border-color: rgba(43, 102, 217, 0.46);
|
||||
background: #f8fbff;
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.mobile-vault-filter-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.mobile-vault-filter-trigger-icon,
|
||||
.mobile-vault-filter-item-icon {
|
||||
@apply inline-flex shrink-0 items-center justify-center;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-trigger-label {
|
||||
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-chevron {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-trigger.active .mobile-vault-filter-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mobile-vault-filter-menu {
|
||||
@apply left-0 right-auto max-h-[280px] min-w-full overflow-auto;
|
||||
min-width: max(100%, 168px);
|
||||
}
|
||||
|
||||
.mobile-vault-filter-control:last-child .mobile-vault-filter-menu {
|
||||
@apply left-auto right-0;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-item {
|
||||
@apply gap-2;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-item-main {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
|
||||
.mobile-vault-filter-item-main span:last-child {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.list-count {
|
||||
@@ -262,6 +348,10 @@ select.input.duplicate-mode-toolbar-select {
|
||||
@apply w-9 min-w-9 justify-center gap-0 p-0;
|
||||
}
|
||||
|
||||
.sort-trigger.sort-trigger-labeled {
|
||||
@apply h-[34px] w-auto min-w-0 gap-1.5 rounded-[10px] px-3;
|
||||
}
|
||||
|
||||
.sort-trigger.active {
|
||||
background: #e9f1ff;
|
||||
border-color: #a9c2ee;
|
||||
@@ -303,6 +393,30 @@ select.input.duplicate-mode-toolbar-select {
|
||||
@apply h-3.5 w-3.5 shrink-0;
|
||||
}
|
||||
|
||||
.desktop-create-menu-wrap {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.desktop-create-trigger {
|
||||
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0];
|
||||
}
|
||||
|
||||
.desktop-create-trigger .btn-icon {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.duplicates-helper-toolbar {
|
||||
@apply justify-start pb-0.5;
|
||||
}
|
||||
|
||||
.duplicates-helper-toolbar .btn.small {
|
||||
@apply h-[34px] rounded-[10px] px-3 py-0 text-[13px];
|
||||
}
|
||||
|
||||
.list-count-status {
|
||||
@apply mb-1 pl-1;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
@apply min-h-0 overflow-auto p-2;
|
||||
/* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring
|
||||
|
||||
Reference in New Issue
Block a user