diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index 3895a0b68..81aaf8dfc 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -478,6 +478,12 @@ const ItemsList: React.FC = () => { const folders = getFoldersWithCounts(); + /** + * Check if all items are in folders (no items at root level but items exist in folders). + * This is used to show a helpful message when the user has imported credentials that were all in folders. + */ + const hasItemsInFoldersOnly = items.length > 0 && items.every((item: Item) => item.FolderId !== null); + if (isLoading) { return (
@@ -490,28 +496,35 @@ const ItemsList: React.FC = () => {
- +

+ {getFilterTitle()} + + ({filteredItems.length}) + +

+ + + + + )} {/* Edit and Delete buttons when inside a folder */} {currentFolderId && (
@@ -536,7 +549,7 @@ const ItemsList: React.FC = () => {
)} - {showFilterMenu && ( + {showFilterMenu && !(hasItemsInFoldersOnly && !currentFolderId) && ( <>
{

- ) : filteredItems.length === 0 && folders.length === 0 ? ( + ) : filteredItems.length === 0 && folders.length === 0 && !hasItemsInFoldersOnly ? (
{/* Show filter/search-specific messages only when actively filtering or searching */} {(filterType !== 'all' || searchTerm) && ( @@ -720,6 +733,35 @@ const ItemsList: React.FC = () => {

)}
+ ) : hasItemsInFoldersOnly && filteredItems.length === 0 && !currentFolderId && !searchTerm ? ( + /* Show message when all items are in folders and we're at root level */ + <> + {/* Folders as inline pills */} +
+ {folders.map(folder => ( + handleFolderClick(folder.id, folder.name)} + /> + ))} + +
+
+

{t('items.allItemsInFolders')}

+
+ ) : ( <> {/* Folders as inline pills (only show at root level when not searching) */} diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 6a158403a..ba72e066f 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -194,6 +194,7 @@ "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.", + "allItemsInFolders": "All your items are organized in folders. Click a folder above to view your credentials, or use the search to find specific items.", "deleteFolder": "Delete Folder", "deleteFolderKeepItems": "Delete folder only", "deleteFolderKeepItemsDescription": "Items will be moved back to the main list.", diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx index 51db1562d..2bbb14609 100644 --- a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -9,8 +9,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; import type { Folder } from '@/utils/db/repositories/FolderRepository'; -import type { Item } from '@/utils/dist/core/models/vault'; -import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; +import type { Item, ItemType } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; @@ -31,6 +31,37 @@ import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { useDialog } from '@/context/DialogContext'; +/** + * Filter types for the items list. + */ +type FilterType = 'all' | 'passkeys' | 'attachments' | ItemType; + +/** + * Check if a filter is an item type filter. + */ +const isItemTypeFilter = (filter: FilterType): filter is ItemType => { + return Object.values(ItemTypes).includes(filter as ItemType); +}; + +/** + * Item type filter option configuration. + */ +type ItemTypeOption = { + type: ItemType; + titleKey: string; + iconName: keyof typeof MaterialIcons.glyphMap; +}; + +/** + * Available item type filter options with icons. + */ +const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [ + { type: ItemTypes.Login, titleKey: 'itemTypes.login.title', iconName: 'key' }, + { type: ItemTypes.Alias, titleKey: 'itemTypes.alias.title', iconName: 'person' }, + { type: ItemTypes.CreditCard, titleKey: 'itemTypes.creditCard.title', iconName: 'credit-card' }, + { type: ItemTypes.Note, titleKey: 'itemTypes.note.title', iconName: 'description' }, +]; + /** * Folder view screen - displays items within a specific folder. * Simplified view with search scoped to this folder only. @@ -47,12 +78,15 @@ export default function FolderViewScreen(): React.ReactNode { const [itemsList, setItemsList] = useState([]); const [folder, setFolder] = useState(null); - const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200); + // No minimum loading delay for folder view since data is already in memory + const [isLoadingItems, setIsLoadingItems] = useState(false); const [refreshing, setRefreshing] = useMinDurationLoading(false, 200); const { executeVaultMutation } = useVaultMutate(); - // Search state (scoped to this folder) + // Search and filter state (scoped to this folder) const [searchQuery, setSearchQuery] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [showFilterMenu, setShowFilterMenu] = useState(false); // Folder modals const [showEditFolderModal, setShowEditFolderModal] = useState(false); @@ -66,16 +100,55 @@ export default function FolderViewScreen(): React.ReactNode { const isDatabaseAvailable = dbContext.dbAvailable; /** - * Filter items by search query (within this folder only). + * Get the title based on the active filter. + * Shows "Items" for 'all' filter since folder name is already in the navigation header. + */ + const getFilterTitle = useCallback((): string => { + switch (filterType) { + case 'passkeys': + return t('items.filters.passkeys'); + case 'attachments': + return t('common.attachments'); + case 'all': + return t('items.title'); + default: + if (isItemTypeFilter(filterType)) { + const itemTypeOption = ITEM_TYPE_OPTIONS.find(opt => opt.type === filterType); + if (itemTypeOption) { + return t(itemTypeOption.titleKey); + } + } + return t('items.title'); + } + }, [filterType, t]); + + /** + * Filter items by search query and type (within this folder only). */ const filteredItems = useMemo(() => { - const searchLower = searchQuery.toLowerCase().trim(); - - if (!searchLower) { - return itemsList; - } - return itemsList.filter(item => { + // Apply type filter + let passesTypeFilter = true; + + if (filterType === 'passkeys') { + passesTypeFilter = item.HasPasskey === true; + } else if (filterType === 'attachments') { + passesTypeFilter = item.HasAttachment === true; + } else if (isItemTypeFilter(filterType)) { + passesTypeFilter = item.ItemType === filterType; + } + + if (!passesTypeFilter) { + return false; + } + + // Apply search filter + const searchLower = searchQuery.toLowerCase().trim(); + + if (!searchLower) { + return true; + } + const searchableFields = [ item.Name?.toLowerCase() || '', getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '', @@ -90,7 +163,7 @@ export default function FolderViewScreen(): React.ReactNode { searchableFields.some(field => field.includes(word)) ); }); - }, [itemsList, searchQuery]); + }, [itemsList, searchQuery, filterType]); /** * Load items in this folder and folder details. @@ -368,14 +441,61 @@ export default function FolderViewScreen(): React.ReactNode { color: colors.textMuted, fontSize: 20, }, - // Item count styles - itemCountContainer: { - marginBottom: 12, + // Filter button styles + filterButton: { + alignItems: 'center', + flexDirection: 'row', + marginBottom: 16, + gap: 8, }, - itemCountText: { + filterButtonText: { + color: colors.text, + fontSize: 22, + fontWeight: 'bold', + lineHeight: 28, + }, + filterCount: { color: colors.textMuted, + fontSize: 16, + lineHeight: 22, + }, + // Filter menu styles + filterMenu: { + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 8, + borderWidth: 1, + marginBottom: 8, + overflow: 'hidden', + }, + filterMenuItem: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + filterMenuItemWithIcon: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + }, + filterMenuItemIcon: { + width: 18, + }, + filterMenuItemActive: { + backgroundColor: colors.primary + '20', + }, + filterMenuItemText: { + color: colors.text, fontSize: 14, }, + filterMenuItemTextActive: { + color: colors.primary, + fontWeight: '600', + }, + filterMenuSeparator: { + backgroundColor: colors.accentBorder, + height: 1, + marginVertical: 4, + }, // Empty state styles emptyText: { color: colors.textMuted, @@ -420,11 +540,135 @@ export default function FolderViewScreen(): React.ReactNode { }); /** - * Render the list header with search. + * Render the filter menu. + */ + const renderFilterMenu = (): React.ReactNode => { + if (!showFilterMenu) { + return null; + } + + return ( + + {/* All items filter */} + { + setFilterType('all'); + setShowFilterMenu(false); + }} + > + + {t('items.filters.all')} + + + + + + {/* Item type filters */} + {ITEM_TYPE_OPTIONS.map((option) => ( + { + setFilterType(option.type); + setShowFilterMenu(false); + }} + > + + + {t(option.titleKey)} + + + ))} + + + + {/* Passkeys filter */} + { + setFilterType('passkeys'); + setShowFilterMenu(false); + }} + > + + {t('items.filters.passkeys')} + + + + {/* Attachments filter */} + { + setFilterType('attachments'); + setShowFilterMenu(false); + }} + > + + {t('common.attachments')} + + + + ); + }; + + /** + * Render the list header with filter and search. */ const renderListHeader = (): React.ReactNode => { return ( + {/* Filter button */} + setShowFilterMenu(!showFilterMenu)} + > + + {getFilterTitle()} + + + ({filteredItems.length}) + + + + + {/* Filter menu */} + {renderFilterMenu()} + {/* Search input */} { + return itemsList.length > 0 && itemsList.every((item: Item) => item.FolderId !== null); + }, [itemsList]); + /** * Filter items by folder, type, and search query. */ @@ -251,6 +259,8 @@ export default function ItemsScreen(): React.ReactNode { const tabPressSub = emitter.addListener('tabPress', (routeName: string) => { if (routeName === 'items' && isTabFocused) { + // Reset search and scroll to top when tapping the tab again + setSearchQuery(''); flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); } }); @@ -358,6 +368,17 @@ export default function ItemsScreen(): React.ReactNode { */ headerTitle: (): React.ReactNode => { if (Platform.OS === 'android') { + // When all items are in folders, show simple title without dropdown + if (hasItemsInFoldersOnly) { + return ( + + ); + } return ( {t('items.title')}; }, }); - }, [navigation, t, getFilterTitle, filteredItems.length, showFilterMenu]); + }, [navigation, t, getFilterTitle, filteredItems.length, showFilterMenu, hasItemsInFoldersOnly]); /** * Delete an item (move to trash). @@ -789,7 +810,7 @@ export default function ItemsScreen(): React.ReactNode { * Render the Android filter menu as an absolute overlay. */ const renderAndroidFilterOverlay = (): React.ReactNode => { - if (Platform.OS !== 'android' || !showFilterMenu) { + if (Platform.OS !== 'android' || !showFilterMenu || hasItemsInFoldersOnly) { return null; } @@ -927,60 +948,38 @@ export default function ItemsScreen(): React.ReactNode { {/* Large header with logo (iOS only) */} {Platform.OS === 'ios' && ( - setShowFilterMenu(!showFilterMenu)} - > - - - {getFilterTitle()} - - - ({filteredItems.length}) - - - - )} - - {/* Filter menu (iOS only - Android uses absolute overlay) */} - {Platform.OS === 'ios' && renderFilterMenu()} - - {/* Folder pills */} - {foldersWithCounts.length > 0 && ( - - {foldersWithCounts.map((folder) => ( - handleFolderClick(folder.id)} + hasItemsInFoldersOnly ? ( + /* When all items are in folders, show simple title without dropdown */ + + + + {t('items.title')} + + + ) : ( + /* Normal filter dropdown when there are items at root */ + setShowFilterMenu(!showFilterMenu)} + > + + + {getFilterTitle()} + + + ({filteredItems.length}) + + - ))} - setShowFolderModal(true)} - > - - {t('items.folders.newFolder')} - + ) )} - {/* New folder button when no folders exist */} - {foldersWithCounts.length === 0 && !searchQuery && ( - - setShowFolderModal(true)} - > - - {t('items.folders.newFolder')} - - - )} + {/* Filter menu (iOS only - Android uses absolute overlay, only when not all items in folders) */} + {Platform.OS === 'ios' && !hasItemsInFoldersOnly && renderFilterMenu()} {/* Search input */} @@ -1013,6 +1012,26 @@ export default function ItemsScreen(): React.ReactNode { )} + + {/* Folder pills (shown below search when not searching) */} + {!searchQuery && ( + + {foldersWithCounts.map((folder) => ( + handleFolderClick(folder.id)} + /> + ))} + setShowFolderModal(true)} + > + + {t('items.folders.newFolder')} + + + )} ); }; @@ -1047,6 +1066,10 @@ export default function ItemsScreen(): React.ReactNode { if (isItemTypeFilter(filterType)) { return t('items.noItemsOfTypeFound', { type: getFilterTitle() }); } + // All items are in folders - show helpful message + if (hasItemsInFoldersOnly) { + return t('items.allItemsInFolders'); + } // No search, no filter - truly empty vault return t('items.noItemsFound'); }; diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 4348779ce..ae18dd054 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -419,6 +419,7 @@ "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.", + "allItemsInFolders": "All your items are organized in folders. Tap a folder above to view your credentials, or use the search to find specific items.", "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", diff --git a/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor b/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor index 5c02837e2..6366bbec6 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor @@ -19,15 +19,24 @@ Description="@Localizer["PageDescription"]">
- + } + else + { +

+ @GetFilterTitle()

- - - - + } @if (IsInFolder) {