mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-19 22:06:08 -04:00
Add better active filter state with clear filter action (#1473)
This commit is contained in:
@@ -685,19 +685,59 @@ const ItemsList: React.FC = () => {
|
||||
)}
|
||||
</>
|
||||
) : filteredItems.length === 0 && folders.length === 0 ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-3 mb-10">
|
||||
{/* Show filter/search-specific messages only when actively filtering or searching */}
|
||||
{(filterType !== 'all' || searchTerm) && (
|
||||
<p>
|
||||
{filterType === 'passkeys'
|
||||
? t('items.noPasskeysFound')
|
||||
: filterType === 'attachments'
|
||||
? t('items.noAttachmentsFound')
|
||||
: isItemTypeFilter(filterType)
|
||||
? t('items.noItemsOfTypeFound', { type: getFilterTitle() })
|
||||
: t('items.noMatchingItems')
|
||||
}
|
||||
</p>
|
||||
<>
|
||||
<p>
|
||||
{/* Different messages based on what's causing no results */}
|
||||
{searchTerm && filterType !== 'all'
|
||||
// Both search and filter active
|
||||
? t('items.noMatchingItemsWithFilter', { filter: getFilterTitle(), search: searchTerm })
|
||||
: searchTerm
|
||||
// Only search active
|
||||
? t('items.noMatchingItemsSearch', { search: searchTerm })
|
||||
// Only filter active (no search)
|
||||
: filterType === 'passkeys'
|
||||
? t('items.noPasskeysFound')
|
||||
: filterType === 'attachments'
|
||||
? t('items.noAttachmentsFound')
|
||||
: isItemTypeFilter(filterType)
|
||||
? t('items.noItemsOfTypeFound', { type: getFilterTitle() })
|
||||
: t('items.noMatchingItems')
|
||||
}
|
||||
</p>
|
||||
{/* Clear filter/search buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
{t('items.clearSearch')}
|
||||
</button>
|
||||
)}
|
||||
{filterType !== 'all' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterType('all');
|
||||
localStorage.removeItem(FILTER_STORAGE_KEY);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
{t('items.clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Show help text when inside an empty folder */}
|
||||
{currentFolderId && (
|
||||
@@ -757,6 +797,39 @@ const ItemsList: React.FC = () => {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Clear filter/search pills at bottom of list when filtering or searching */}
|
||||
{(filterType !== 'all' || searchTerm) && (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4 pt-4">
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
{t('items.clearSearch')}
|
||||
</button>
|
||||
)}
|
||||
{filterType !== 'all' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterType('all');
|
||||
localStorage.removeItem(FILTER_STORAGE_KEY);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
{t('items.clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recently Deleted link (only show at root level when not searching and not filtering) */}
|
||||
{!currentFolderId && !searchTerm && filterType === 'all' && (
|
||||
<button
|
||||
|
||||
@@ -189,6 +189,10 @@
|
||||
"noAttachmentsFound": "No items with attachments found",
|
||||
"noItemsOfTypeFound": "No {{type}} items found",
|
||||
"noMatchingItems": "No matching items found",
|
||||
"noMatchingItemsSearch": "No items matching \"{{search}}\"",
|
||||
"noMatchingItemsWithFilter": "No {{filter}} items matching \"{{search}}\"",
|
||||
"clearSearch": "Clear search",
|
||||
"clearFilter": "Clear filter",
|
||||
"emptyFolderHint": "This folder is empty. To move items to this folder, edit the item and tap the folder icon in the name field.",
|
||||
"deleteFolder": "Delete Folder",
|
||||
"deleteFolderKeepItems": "Delete folder only",
|
||||
|
||||
@@ -540,13 +540,59 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
fontSize: 20,
|
||||
},
|
||||
// Empty state styles
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
marginTop: 24,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginTop: 24,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
clearButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
clearSearchButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
clearSearchButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
},
|
||||
clearFilterButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary + '20',
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
clearFilterButtonText: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
},
|
||||
// Footer clear buttons styles (at bottom of list)
|
||||
footerClearContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
// FAB styles
|
||||
fab: {
|
||||
alignItems: 'center',
|
||||
@@ -805,20 +851,95 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate message based on search and filter state.
|
||||
*/
|
||||
const getMessage = (): string => {
|
||||
// Both search and filter active
|
||||
if (searchQuery && filterType !== 'all') {
|
||||
return t('items.noMatchingItemsWithFilter', { filter: getFilterTitle(), search: searchQuery });
|
||||
}
|
||||
// Only search active
|
||||
if (searchQuery) {
|
||||
return t('items.noMatchingItemsSearch', { search: searchQuery });
|
||||
}
|
||||
// Only filter active (no search)
|
||||
if (filterType === 'passkeys') {
|
||||
return t('items.noPasskeysFound');
|
||||
}
|
||||
if (filterType === 'attachments') {
|
||||
return t('items.noAttachmentsFound');
|
||||
}
|
||||
if (isItemTypeFilter(filterType)) {
|
||||
return t('items.noItemsOfTypeFound', { type: getFilterTitle() });
|
||||
}
|
||||
// No search, no filter - truly empty vault
|
||||
return t('items.noItemsFound');
|
||||
};
|
||||
|
||||
const showClearButtons = searchQuery || filterType !== 'all';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery
|
||||
? t('items.noMatchingItems')
|
||||
: filterType === 'passkeys'
|
||||
? t('items.noPasskeysFound')
|
||||
: filterType === 'attachments'
|
||||
? t('items.noAttachmentsFound')
|
||||
: isItemTypeFilter(filterType)
|
||||
? t('items.noItemsOfTypeFound', { type: getFilterTitle() })
|
||||
: t('items.noItemsFound')
|
||||
}
|
||||
</Text>
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>{getMessage()}</Text>
|
||||
|
||||
{/* Clear search/filter buttons */}
|
||||
{showClearButtons && (
|
||||
<View style={styles.clearButtonsContainer}>
|
||||
{searchQuery && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearSearchButton}
|
||||
onPress={() => setSearchQuery('')}
|
||||
>
|
||||
<MaterialIcons name="close" size={16} color={colors.text} />
|
||||
<Text style={styles.clearSearchButtonText}>{t('items.clearSearch')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{filterType !== 'all' && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearFilterButton}
|
||||
onPress={() => setFilterType('all')}
|
||||
>
|
||||
<MaterialIcons name="close" size={16} color={colors.primary} />
|
||||
<Text style={styles.clearFilterButtonText}>{t('items.clearFilter')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render list footer with clear filter/search buttons.
|
||||
* Only shown when there are items and a filter or search is active.
|
||||
*/
|
||||
const renderListFooter = (): React.ReactNode => {
|
||||
// Don't show footer if loading, no items, or no active filter/search
|
||||
if (isLoadingItems || filteredItems.length === 0 || (filterType === 'all' && !searchQuery)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.footerClearContainer}>
|
||||
{searchQuery && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearSearchButton}
|
||||
onPress={() => setSearchQuery('')}
|
||||
>
|
||||
<MaterialIcons name="close" size={16} color={colors.text} />
|
||||
<Text style={styles.clearSearchButtonText}>{t('items.clearSearch')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{filterType !== 'all' && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearFilterButton}
|
||||
onPress={() => setFilterType('all')}
|
||||
>
|
||||
<MaterialIcons name="close" size={16} color={colors.primary} />
|
||||
<Text style={styles.clearFilterButtonText}>{t('items.clearFilter')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -872,6 +993,7 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
)
|
||||
}
|
||||
ListEmptyComponent={renderEmptyComponent() as React.ReactElement}
|
||||
ListFooterComponent={renderListFooter() as React.ReactElement}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
|
||||
@@ -520,10 +520,14 @@
|
||||
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
|
||||
"searchPlaceholder": "Search vault...",
|
||||
"noMatchingItems": "No matching items found",
|
||||
"noMatchingItemsSearch": "No items matching \"{{search}}\"",
|
||||
"noMatchingItemsWithFilter": "No {{filter}} items matching \"{{search}}\"",
|
||||
"noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No items with attachments found",
|
||||
"noItemsOfTypeFound": "No {{type}} items found",
|
||||
"clearSearch": "Clear search",
|
||||
"clearFilter": "Clear filter",
|
||||
"recentEmails": "Recent emails",
|
||||
"loadingEmails": "Loading emails...",
|
||||
"noEmailsYet": "No emails received yet.",
|
||||
|
||||
@@ -152,6 +152,20 @@ else
|
||||
@if (DbService.Settings.CredentialsViewMode == "table")
|
||||
{
|
||||
<div class="px-4 min-h-[250px]">
|
||||
@* Active filter indicator - show persistently when a filter is active *@
|
||||
@if (FilterType != ItemFilterType.All)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">@Localizer["FilteringBy"]</span>
|
||||
<button @onclick="ClearFilter" class="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors">
|
||||
<span>@GetFilterTitle()</span>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<ItemsTable Credentials="@FilteredAndSortedItems.ToList()" SortOrder="@SortOrder" />
|
||||
@* Infinite scroll sentinel for table view *@
|
||||
@if (HasMoreItems)
|
||||
@@ -174,6 +188,21 @@ else
|
||||
else
|
||||
{
|
||||
<div class="px-4 mb-4 min-h-[250px]">
|
||||
@* Active filter indicator - show persistently when a filter is active *@
|
||||
@if (FilterType != ItemFilterType.All)
|
||||
{
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">@Localizer["FilteringBy"]</span>
|
||||
<button @onclick="ClearFilter" class="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg transition-colors">
|
||||
<span>@GetFilterTitle()</span>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Folders section - only show at root level *@
|
||||
@if (!IsInFolder && !IsSearching)
|
||||
{
|
||||
@@ -544,6 +573,16 @@ else
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the active filter and resets to showing all items.
|
||||
/// </summary>
|
||||
private void ClearFilter()
|
||||
{
|
||||
FilterType = ItemFilterType.All;
|
||||
VisibleItemCount = BatchSize; // Reset visible items when filter changes
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads more items for infinite scroll. Called from JavaScript via IntersectionObserver.
|
||||
/// </summary>
|
||||
|
||||
@@ -236,4 +236,14 @@
|
||||
<value>Loading more...</value>
|
||||
<comment>Text shown when loading more items during infinite scroll</comment>
|
||||
</data>
|
||||
|
||||
<!-- Clear Filter -->
|
||||
<data name="ClearFilter" xml:space="preserve">
|
||||
<value>Clear filter</value>
|
||||
<comment>Button text for clearing the active filter</comment>
|
||||
</data>
|
||||
<data name="FilteringBy" xml:space="preserve">
|
||||
<value>Filtering by:</value>
|
||||
<comment>Label shown when a filter is active</comment>
|
||||
</data>
|
||||
</root>
|
||||
Reference in New Issue
Block a user