-
setShowFilterMenu(!showFilterMenu)}
- className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none min-w-0"
- >
-
- {getFilterTitle()}
-
- ({filteredItems.length})
-
+ {/* When all items are in folders at root level, show simple title without dropdown */}
+ {hasItemsInFoldersOnly && !currentFolderId ? (
+
+ {t('items.title')}
- setShowFilterMenu(!showFilterMenu)}
+ className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none min-w-0"
>
-
-
-
+
+ {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"]">
-
-
- @GetFilterTitle()
- (@TotalFilteredItems)
+ @if (IsInFolder || HasRootItems)
+ {
+
+
+ @GetFilterTitle()
+ (@TotalFilteredItems)
+
+
+
+
+
+ }
+ else
+ {
+
+ @GetFilterTitle()
-
-
-
-
+ }
@if (IsInFolder)
{
@@ -271,6 +280,10 @@ else
{
@Localizer["EmptyFolderMessage"]
}
+ else if (HasItemsInFoldersOnly)
+ {
+ @Localizer["AllItemsInFoldersMessage"]
+ }
else
{
@Localizer["NoItemsFound"]
@@ -451,6 +464,16 @@ else
///
private bool IsSearching => !string.IsNullOrEmpty(SearchTerm);
+ ///
+ /// Gets whether all items are in folders (no items at root level but items exist in folders).
+ ///
+ private bool HasItemsInFoldersOnly => Items.Count > 0 && Items.All(x => x.FolderId != null);
+
+ ///
+ /// Gets whether there are items at the root level (not in any folder).
+ ///
+ private bool HasRootItems => Items.Any(x => x.FolderId == null);
+
///
/// Gets the items filtered and sorted according to the current filter and sort order (all items, not paginated).
///
diff --git a/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx
index 6e0a76929..7967f6f0c 100644
--- a/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx
+++ b/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx
@@ -186,6 +186,10 @@
This folder is empty.
Empty state message when folder has no items
+
+ All your items are organized in folders. Click a folder above to view your credentials, or use the search to find specific items.
+ Empty state message when all items are in folders and none at root level
+