Tweak OOBE when all items are in folders on all clients (#1481)

This commit is contained in:
Leendert de Borst
2026-01-25 18:54:14 +01:00
parent ee5cd0b6d9
commit de5926dc6e
7 changed files with 437 additions and 99 deletions

View File

@@ -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 (
<div className="flex justify-center items-center p-8">
@@ -490,28 +496,35 @@ const ItemsList: React.FC = () => {
<div>
<div className="flex justify-between items-center gap-2 mb-4">
<div className="relative min-w-0 flex-1 flex items-center gap-2">
<button
onClick={() => 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"
>
<h2 className="flex items-baseline gap-1.5 min-w-0 overflow-hidden">
<span className="truncate">{getFilterTitle()}</span>
<span className="text-sm text-gray-500 dark:text-gray-400 shrink-0">
({filteredItems.length})
</span>
{/* When all items are in folders at root level, show simple title without dropdown */}
{hasItemsInFoldersOnly && !currentFolderId ? (
<h2 className="text-gray-900 dark:text-white text-xl min-w-0">
<span className="truncate">{t('items.title')}</span>
</h2>
<svg
className="w-4 h-4 mt-1 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
) : (
<button
onClick={() => 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"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<h2 className="flex items-baseline gap-1.5 min-w-0 overflow-hidden">
<span className="truncate">{getFilterTitle()}</span>
<span className="text-sm text-gray-500 dark:text-gray-400 shrink-0">
({filteredItems.length})
</span>
</h2>
<svg
className="w-4 h-4 mt-1 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
{/* Edit and Delete buttons when inside a folder */}
{currentFolderId && (
<div className="flex items-center gap-1 shrink-0">
@@ -536,7 +549,7 @@ const ItemsList: React.FC = () => {
</button>
</div>
)}
{showFilterMenu && (
{showFilterMenu && !(hasItemsInFoldersOnly && !currentFolderId) && (
<>
<div
className="fixed inset-0 z-10"
@@ -658,7 +671,7 @@ const ItemsList: React.FC = () => {
</p>
</div>
</>
) : filteredItems.length === 0 && folders.length === 0 ? (
) : filteredItems.length === 0 && folders.length === 0 && !hasItemsInFoldersOnly ? (
<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) && (
@@ -720,6 +733,35 @@ const ItemsList: React.FC = () => {
</p>
)}
</div>
) : hasItemsInFoldersOnly && filteredItems.length === 0 && !currentFolderId && !searchTerm ? (
/* Show message when all items are in folders and we're at root level */
<>
{/* Folders as inline pills */}
<div className="flex flex-wrap items-center gap-2 mb-4">
{folders.map(folder => (
<FolderPill
key={folder.id}
folder={folder}
onClick={() => handleFolderClick(folder.id, folder.name)}
/>
))}
<button
onClick={handleAddFolder}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-full transition-colors focus:outline-none text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700/50"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<svg className="w-3 h-3 -ml-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
<div className="text-gray-500 dark:text-gray-400 text-sm">
<p>{t('items.allItemsInFolders')}</p>
</div>
</>
) : (
<>
{/* Folders as inline pills (only show at root level when not searching) */}

View File

@@ -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.",

View File

@@ -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<Item[]>([]);
const [folder, setFolder] = useState<Folder | null>(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<FilterType>('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 (
<ThemedView style={styles.filterMenu}>
{/* All items filter */}
<TouchableOpacity
style={[
styles.filterMenuItem,
filterType === 'all' && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType('all');
setShowFilterMenu(false);
}}
>
<ThemedText style={[
styles.filterMenuItemText,
filterType === 'all' && styles.filterMenuItemTextActive
]}>
{t('items.filters.all')}
</ThemedText>
</TouchableOpacity>
<ThemedView style={styles.filterMenuSeparator} />
{/* Item type filters */}
{ITEM_TYPE_OPTIONS.map((option) => (
<TouchableOpacity
key={option.type}
style={[
styles.filterMenuItem,
styles.filterMenuItemWithIcon,
filterType === option.type && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType(option.type);
setShowFilterMenu(false);
}}
>
<MaterialIcons
name={option.iconName}
size={18}
color={filterType === option.type ? colors.primary : colors.textMuted}
style={styles.filterMenuItemIcon}
/>
<ThemedText style={[
styles.filterMenuItemText,
filterType === option.type && styles.filterMenuItemTextActive
]}>
{t(option.titleKey)}
</ThemedText>
</TouchableOpacity>
))}
<ThemedView style={styles.filterMenuSeparator} />
{/* Passkeys filter */}
<TouchableOpacity
style={[
styles.filterMenuItem,
filterType === 'passkeys' && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType('passkeys');
setShowFilterMenu(false);
}}
>
<ThemedText style={[
styles.filterMenuItemText,
filterType === 'passkeys' && styles.filterMenuItemTextActive
]}>
{t('items.filters.passkeys')}
</ThemedText>
</TouchableOpacity>
{/* Attachments filter */}
<TouchableOpacity
style={[
styles.filterMenuItem,
filterType === 'attachments' && styles.filterMenuItemActive
]}
onPress={() => {
setFilterType('attachments');
setShowFilterMenu(false);
}}
>
<ThemedText style={[
styles.filterMenuItemText,
filterType === 'attachments' && styles.filterMenuItemTextActive
]}>
{t('common.attachments')}
</ThemedText>
</TouchableOpacity>
</ThemedView>
);
};
/**
* Render the list header with filter and search.
*/
const renderListHeader = (): React.ReactNode => {
return (
<ThemedView>
{/* Filter button */}
<TouchableOpacity
style={styles.filterButton}
onPress={() => setShowFilterMenu(!showFilterMenu)}
>
<ThemedText style={styles.filterButtonText}>
{getFilterTitle()}
</ThemedText>
<ThemedText style={styles.filterCount}>
({filteredItems.length})
</ThemedText>
<MaterialIcons
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={24}
color={colors.text}
/>
</TouchableOpacity>
{/* Filter menu */}
{renderFilterMenu()}
{/* Search input */}
<ThemedView style={styles.searchContainer}>
<MaterialIcons

View File

@@ -166,6 +166,14 @@ export default function ItemsScreen(): React.ReactNode {
}
}, [filterType, t]);
/**
* 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 = useMemo(() => {
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 (
<AndroidHeader
title={t('items.title')}
subtitle=""
onTitlePress={undefined}
isDropdownOpen={false}
/>
);
}
return (
<AndroidHeader
title={getFilterTitle()}
@@ -370,7 +391,7 @@ export default function ItemsScreen(): React.ReactNode {
return <Text>{t('items.title')}</Text>;
},
});
}, [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 {
<ThemedView>
{/* Large header with logo (iOS only) */}
{Platform.OS === 'ios' && (
<TouchableOpacity
style={styles.filterButton}
onPress={() => setShowFilterMenu(!showFilterMenu)}
>
<Logo width={40} height={40} />
<ThemedText style={styles.filterButtonText}>
{getFilterTitle()}
</ThemedText>
<ThemedText style={styles.filterCount}>
({filteredItems.length})
</ThemedText>
<MaterialIcons
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={28}
color={colors.text}
/>
</TouchableOpacity>
)}
{/* Filter menu (iOS only - Android uses absolute overlay) */}
{Platform.OS === 'ios' && renderFilterMenu()}
{/* Folder pills */}
{foldersWithCounts.length > 0 && (
<View style={styles.folderPillsContainer}>
{foldersWithCounts.map((folder) => (
<FolderPill
key={folder.id}
folder={folder}
onPress={() => handleFolderClick(folder.id)}
hasItemsInFoldersOnly ? (
/* When all items are in folders, show simple title without dropdown */
<View style={styles.filterButton}>
<Logo width={40} height={40} />
<ThemedText style={styles.filterButtonText}>
{t('items.title')}
</ThemedText>
</View>
) : (
/* Normal filter dropdown when there are items at root */
<TouchableOpacity
style={styles.filterButton}
onPress={() => setShowFilterMenu(!showFilterMenu)}
>
<Logo width={40} height={40} />
<ThemedText style={styles.filterButtonText}>
{getFilterTitle()}
</ThemedText>
<ThemedText style={styles.filterCount}>
({filteredItems.length})
</ThemedText>
<MaterialIcons
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={28}
color={colors.text}
/>
))}
<TouchableOpacity
style={styles.newFolderButton}
onPress={() => setShowFolderModal(true)}
>
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
</TouchableOpacity>
</View>
)
)}
{/* New folder button when no folders exist */}
{foldersWithCounts.length === 0 && !searchQuery && (
<View style={styles.folderPillsContainer}>
<TouchableOpacity
style={styles.newFolderButton}
onPress={() => setShowFolderModal(true)}
>
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
</TouchableOpacity>
</View>
)}
{/* Filter menu (iOS only - Android uses absolute overlay, only when not all items in folders) */}
{Platform.OS === 'ios' && !hasItemsInFoldersOnly && renderFilterMenu()}
{/* Search input */}
<ThemedView style={styles.searchContainer}>
@@ -1013,6 +1012,26 @@ export default function ItemsScreen(): React.ReactNode {
</TouchableOpacity>
)}
</ThemedView>
{/* Folder pills (shown below search when not searching) */}
{!searchQuery && (
<View style={styles.folderPillsContainer}>
{foldersWithCounts.map((folder) => (
<FolderPill
key={folder.id}
folder={folder}
onPress={() => handleFolderClick(folder.id)}
/>
))}
<TouchableOpacity
style={styles.newFolderButton}
onPress={() => setShowFolderModal(true)}
>
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
</TouchableOpacity>
</View>
)}
</ThemedView>
);
};
@@ -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');
};

View File

@@ -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",

View File

@@ -19,15 +19,24 @@
Description="@Localizer["PageDescription"]">
<TitleActions>
<div class="relative flex items-center gap-2">
<button @onclick="ToggleFilterDropdown" id="filterButton" class="flex items-center gap-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none">
<h1 class="flex items-baseline gap-1.5 text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
<span>@GetFilterTitle()</span>
<span class="text-base text-gray-500 dark:text-gray-400">(@TotalFilteredItems)</span>
@if (IsInFolder || HasRootItems)
{
<button @onclick="ToggleFilterDropdown" id="filterButton" class="flex items-center gap-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none">
<h1 class="flex items-baseline gap-1.5 text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
<span>@GetFilterTitle()</span>
<span class="text-base text-gray-500 dark:text-gray-400">(@TotalFilteredItems)</span>
</h1>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
}
else
{
<h1 class="text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
@GetFilterTitle()
</h1>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
}
@if (IsInFolder)
{
<button @onclick="ShowEditFolderModal" title="@Localizer["EditFolder"]" class="p-1.5 text-gray-400 hover:text-orange-500 dark:text-gray-500 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
@@ -271,6 +280,10 @@ else
{
<p>@Localizer["EmptyFolderMessage"]</p>
}
else if (HasItemsInFoldersOnly)
{
<p>@Localizer["AllItemsInFoldersMessage"]</p>
}
else
{
<p>@Localizer["NoItemsFound"]</p>
@@ -451,6 +464,16 @@ else
/// </summary>
private bool IsSearching => !string.IsNullOrEmpty(SearchTerm);
/// <summary>
/// Gets whether all items are in folders (no items at root level but items exist in folders).
/// </summary>
private bool HasItemsInFoldersOnly => Items.Count > 0 && Items.All(x => x.FolderId != null);
/// <summary>
/// Gets whether there are items at the root level (not in any folder).
/// </summary>
private bool HasRootItems => Items.Any(x => x.FolderId == null);
/// <summary>
/// Gets the items filtered and sorted according to the current filter and sort order (all items, not paginated).
/// </summary>

View File

@@ -186,6 +186,10 @@
<value>This folder is empty.</value>
<comment>Empty state message when folder has no items</comment>
</data>
<data name="AllItemsInFoldersMessage" xml:space="preserve">
<value>All your items are organized in folders. Click a folder above to view your credentials, or use the search to find specific items.</value>
<comment>Empty state message when all items are in folders and none at root level</comment>
</data>
<!-- Folder Management -->
<data name="NewFolder" xml:space="preserve">