mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-01 09:53:05 -05:00
Tweak OOBE when all items are in folders on all clients (#1481)
This commit is contained in:
@@ -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) */}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user