mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-09-08 11:42:15 -04:00
Add deleted items scaffolding logic (#1404)
This commit is contained in:
7 files changed
+662
-4
No files matched your search
@@ -65,6 +65,14 @@ export default function ItemsLayout(): React.ReactNode {
|
|||||||
title: t('items.emailPreview'),
|
title: t('items.emailPreview'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="deleted"
|
||||||
|
options={{
|
||||||
|
title: t('items.recentlyDeleted.title'),
|
||||||
|
headerBackTitle: t('items.title'),
|
||||||
|
...defaultHeaderOptions,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
|
import type { ItemWithDeletedAt } from '@/utils/db/mappers/ItemMapper';
|
||||||
|
import emitter from '@/utils/EventEmitter';
|
||||||
|
|
||||||
|
import { useColors } from '@/hooks/useColorScheme';
|
||||||
|
import { useVaultMutate } from '@/hooks/useVaultMutate';
|
||||||
|
|
||||||
|
import { ConfirmDeleteModal } from '@/components/items/ConfirmDeleteModal';
|
||||||
|
import LoadingOverlay from '@/components/LoadingOverlay';
|
||||||
|
import { ThemedContainer } from '@/components/themed/ThemedContainer';
|
||||||
|
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
|
||||||
|
import { ThemedText } from '@/components/themed/ThemedText';
|
||||||
|
import { useDb } from '@/context/DbContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate days remaining until permanent deletion.
|
||||||
|
* @param deletedAt - ISO timestamp when item was deleted
|
||||||
|
* @param retentionDays - Number of days to retain (default 30)
|
||||||
|
* @returns Number of days remaining, or 0 if already expired
|
||||||
|
*/
|
||||||
|
const getDaysRemaining = (deletedAt: string, retentionDays: number = 30): number => {
|
||||||
|
const deletedDate = new Date(deletedAt);
|
||||||
|
const expiryDate = new Date(deletedDate.getTime() + retentionDays * 24 * 60 * 60 * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const daysRemaining = Math.ceil((expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
|
||||||
|
return Math.max(0, daysRemaining);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently Deleted page - shows items in trash that can be restored or permanently deleted.
|
||||||
|
*/
|
||||||
|
export default function RecentlyDeletedScreen(): React.ReactNode {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const dbContext = useDb();
|
||||||
|
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ItemWithDeletedAt[]>([]);
|
||||||
|
const [isLoadingItems, setIsLoadingItems] = useState(true);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||||
|
const [showConfirmEmptyAll, setShowConfirmEmptyAll] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load recently deleted items.
|
||||||
|
*/
|
||||||
|
const loadItems = useCallback(async (): Promise<void> => {
|
||||||
|
if (!dbContext.sqliteClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await dbContext.sqliteClient.getRecentlyDeletedItems();
|
||||||
|
setItems(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading deleted items:', err);
|
||||||
|
Toast.show({
|
||||||
|
type: 'error',
|
||||||
|
text1: t('items.errorLoadingItems'),
|
||||||
|
text2: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoadingItems(false);
|
||||||
|
}
|
||||||
|
}, [dbContext.sqliteClient, t]);
|
||||||
|
|
||||||
|
// Load items when screen is focused
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setIsLoadingItems(true);
|
||||||
|
loadItems();
|
||||||
|
}, [loadItems])
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore an item from Recently Deleted.
|
||||||
|
*/
|
||||||
|
const handleRestore = useCallback(async (itemId: string): Promise<void> => {
|
||||||
|
if (!dbContext.sqliteClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeVaultMutation(async () => {
|
||||||
|
await dbContext.sqliteClient!.restoreItem(itemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadItems();
|
||||||
|
emitter.emit('credentialChanged');
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
type: 'success',
|
||||||
|
text1: t('items.recentlyDeleted.itemRestored'),
|
||||||
|
});
|
||||||
|
}, [dbContext.sqliteClient, executeVaultMutation, loadItems, t]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete an item.
|
||||||
|
*/
|
||||||
|
const handlePermanentDelete = useCallback(async (): Promise<void> => {
|
||||||
|
if (!dbContext.sqliteClient || !selectedItemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeVaultMutation(async () => {
|
||||||
|
await dbContext.sqliteClient!.permanentlyDeleteItem(selectedItemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadItems();
|
||||||
|
emitter.emit('credentialChanged');
|
||||||
|
setShowConfirmDelete(false);
|
||||||
|
setSelectedItemId(null);
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
type: 'success',
|
||||||
|
text1: t('items.recentlyDeleted.itemDeleted'),
|
||||||
|
});
|
||||||
|
}, [dbContext.sqliteClient, executeVaultMutation, loadItems, selectedItemId, t]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty all items from Recently Deleted (permanent delete all).
|
||||||
|
*/
|
||||||
|
const handleEmptyAll = useCallback(async (): Promise<void> => {
|
||||||
|
if (!dbContext.sqliteClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeVaultMutation(async () => {
|
||||||
|
for (const item of items) {
|
||||||
|
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadItems();
|
||||||
|
emitter.emit('credentialChanged');
|
||||||
|
setShowConfirmEmptyAll(false);
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
type: 'success',
|
||||||
|
text1: t('items.recentlyDeleted.allItemsDeleted'),
|
||||||
|
});
|
||||||
|
}, [dbContext.sqliteClient, executeVaultMutation, items, loadItems, t]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle closing the delete confirmation modal.
|
||||||
|
*/
|
||||||
|
const handleCloseDeleteModal = useCallback((): void => {
|
||||||
|
setShowConfirmDelete(false);
|
||||||
|
setSelectedItemId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
headerText: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
itemCount: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
emptyAllButton: {
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
emptyAllButtonText: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 40,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyDescription: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
opacity: 0.7,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
itemCard: {
|
||||||
|
backgroundColor: colors.accentBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
itemContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
itemLogo: {
|
||||||
|
borderRadius: 4,
|
||||||
|
height: 32,
|
||||||
|
marginRight: 12,
|
||||||
|
width: 32,
|
||||||
|
},
|
||||||
|
itemLogoPlaceholder: {
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.primary + '20',
|
||||||
|
borderRadius: 4,
|
||||||
|
height: 32,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
width: 32,
|
||||||
|
},
|
||||||
|
itemInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
itemExpiry: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
itemExpiryWarning: {
|
||||||
|
color: colors.destructive,
|
||||||
|
},
|
||||||
|
itemActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
restoreButton: {
|
||||||
|
backgroundColor: colors.primary + '15',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
restoreButtonText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
backgroundColor: colors.destructive + '15',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
deleteButtonText: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingTop: 60,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an item card.
|
||||||
|
*/
|
||||||
|
const renderItem = (item: ItemWithDeletedAt): React.ReactElement => {
|
||||||
|
const daysRemaining = item.DeletedAt ? getDaysRemaining(item.DeletedAt) : 30;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={item.Id} style={styles.itemCard}>
|
||||||
|
<View style={styles.itemContent}>
|
||||||
|
{/* Item logo */}
|
||||||
|
{item.Logo ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: `data:image/png;base64,${Buffer.from(item.Logo).toString('base64')}` }}
|
||||||
|
style={styles.itemLogo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.itemLogoPlaceholder}>
|
||||||
|
<MaterialIcons name="lock" size={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Item info */}
|
||||||
|
<View style={styles.itemInfo}>
|
||||||
|
<Text style={styles.itemName} numberOfLines={1}>
|
||||||
|
{item.Name || t('items.untitled')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.itemExpiry,
|
||||||
|
daysRemaining <= 3 && styles.itemExpiryWarning
|
||||||
|
]}>
|
||||||
|
{daysRemaining > 0
|
||||||
|
? t('items.recentlyDeleted.daysRemaining', { count: daysRemaining })
|
||||||
|
: t('items.recentlyDeleted.expiringSoon')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View style={styles.itemActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.restoreButton}
|
||||||
|
onPress={() => handleRestore(item.Id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.restoreButtonText}>
|
||||||
|
{t('items.recentlyDeleted.restore')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedItemId(item.Id);
|
||||||
|
setShowConfirmDelete(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.deleteButtonText}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedContainer>
|
||||||
|
{isLoading && <LoadingOverlay status={syncStatus} />}
|
||||||
|
|
||||||
|
{isLoadingItems ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ThemedScrollView>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<ThemedText style={styles.itemCount}>
|
||||||
|
{items.length} {items.length === 1 ? 'item' : 'items'}
|
||||||
|
</ThemedText>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.emptyAllButton}
|
||||||
|
onPress={() => setShowConfirmEmptyAll(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.emptyAllButtonText}>
|
||||||
|
{t('items.recentlyDeleted.emptyAll')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.headerText}>
|
||||||
|
{t('items.recentlyDeleted.description')}
|
||||||
|
</ThemedText>
|
||||||
|
{items.map(renderItem)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<ThemedText style={styles.emptyTitle}>
|
||||||
|
{t('items.recentlyDeleted.noItems')}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={styles.emptyDescription}>
|
||||||
|
{t('items.recentlyDeleted.noItemsDescription')}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ThemedScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Delete Modal */}
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
isOpen={showConfirmDelete && !!selectedItemId}
|
||||||
|
onClose={handleCloseDeleteModal}
|
||||||
|
onConfirm={handlePermanentDelete}
|
||||||
|
title={t('items.recentlyDeleted.confirmDeleteTitle')}
|
||||||
|
message={t('items.recentlyDeleted.confirmDeleteMessage')}
|
||||||
|
confirmText={t('items.recentlyDeleted.deletePermanently')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Confirm Empty All Modal */}
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
isOpen={showConfirmEmptyAll}
|
||||||
|
onClose={() => setShowConfirmEmptyAll(false)}
|
||||||
|
onConfirm={handleEmptyAll}
|
||||||
|
title={t('items.recentlyDeleted.confirmEmptyAllTitle')}
|
||||||
|
message={t('items.recentlyDeleted.confirmEmptyAllMessage', { count: items.length })}
|
||||||
|
confirmText={t('items.recentlyDeleted.emptyAll')}
|
||||||
|
/>
|
||||||
|
</ThemedContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,6 +93,9 @@ export default function ItemsScreen(): React.ReactNode {
|
|||||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||||
|
|
||||||
|
// Recently deleted count state
|
||||||
|
const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0);
|
||||||
|
|
||||||
const authContext = useApp();
|
const authContext = useApp();
|
||||||
const dbContext = useDb();
|
const dbContext = useDb();
|
||||||
|
|
||||||
@@ -197,16 +200,18 @@ export default function ItemsScreen(): React.ReactNode {
|
|||||||
}, [itemsList, searchQuery, filterType]);
|
}, [itemsList, searchQuery, filterType]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load items (credentials) and folders.
|
* Load items (credentials), folders, and recently deleted count.
|
||||||
*/
|
*/
|
||||||
const loadItems = useCallback(async (): Promise<void> => {
|
const loadItems = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const [items, loadedFolders] = await Promise.all([
|
const [items, loadedFolders, deletedCount] = await Promise.all([
|
||||||
dbContext.sqliteClient!.getAllItems(),
|
dbContext.sqliteClient!.getAllItems(),
|
||||||
dbContext.sqliteClient!.getAllFolders()
|
dbContext.sqliteClient!.getAllFolders(),
|
||||||
|
dbContext.sqliteClient!.getRecentlyDeletedCount()
|
||||||
]);
|
]);
|
||||||
setItemsList(items);
|
setItemsList(items);
|
||||||
setFolders(loadedFolders);
|
setFolders(loadedFolders);
|
||||||
|
setRecentlyDeletedCount(deletedCount);
|
||||||
setIsLoadingItems(false);
|
setIsLoadingItems(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.show({
|
Toast.show({
|
||||||
@@ -474,6 +479,16 @@ export default function ItemsScreen(): React.ReactNode {
|
|||||||
height: 1,
|
height: 1,
|
||||||
marginVertical: 4,
|
marginVertical: 4,
|
||||||
},
|
},
|
||||||
|
filterMenuItemWithBadge: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
filterMenuItemBadge: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
// Folder pills styles
|
// Folder pills styles
|
||||||
folderPillsContainer: {
|
folderPillsContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -682,6 +697,28 @@ export default function ItemsScreen(): React.ReactNode {
|
|||||||
{t('common.attachments')}
|
{t('common.attachments')}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<ThemedView style={styles.filterMenuSeparator} />
|
||||||
|
|
||||||
|
{/* Recently deleted link */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.filterMenuItem}
|
||||||
|
onPress={() => {
|
||||||
|
setShowFilterMenu(false);
|
||||||
|
router.push('/(tabs)/items/deleted');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.filterMenuItemWithBadge}>
|
||||||
|
<ThemedText style={styles.filterMenuItemText}>
|
||||||
|
{t('items.recentlyDeleted.title')}
|
||||||
|
</ThemedText>
|
||||||
|
{recentlyDeletedCount > 0 && (
|
||||||
|
<ThemedText style={styles.filterMenuItemBadge}>
|
||||||
|
{recentlyDeletedCount}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { useColors } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
interface IConfirmDeleteModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal for confirming permanent deletion of items.
|
||||||
|
*/
|
||||||
|
export const ConfirmDeleteModal: React.FC<IConfirmDeleteModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle confirm action.
|
||||||
|
*/
|
||||||
|
const handleConfirm = async (): Promise<void> => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onConfirm();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error during confirm action:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle close - only allow if not submitting.
|
||||||
|
*/
|
||||||
|
const handleClose = (): void => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
maxWidth: 400,
|
||||||
|
padding: 20,
|
||||||
|
width: '90%',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
buttonsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.accentBackground,
|
||||||
|
borderColor: colors.accentBorder,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
borderRadius: 8,
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
confirmButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
confirmButtonText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={isOpen}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<View style={styles.backdrop}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.message}>{message}</Text>
|
||||||
|
|
||||||
|
<View style={styles.buttonsContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>{t('common.cancel')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.confirmButton,
|
||||||
|
isSubmitting && styles.confirmButtonDisabled
|
||||||
|
]}
|
||||||
|
onPress={handleConfirm}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<ActivityIndicator size="small" color={colors.white} />
|
||||||
|
)}
|
||||||
|
<Text style={styles.confirmButtonText}>
|
||||||
|
{confirmText || t('common.delete')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDeleteModal;
|
||||||
@@ -613,6 +613,25 @@
|
|||||||
"emptyFolderHint": "This folder is empty. To move items to this folder, edit the item and select this folder.",
|
"emptyFolderHint": "This folder is empty. To move items to this folder, edit the item and select this folder.",
|
||||||
"noFolder": "No Folder",
|
"noFolder": "No Folder",
|
||||||
"selectFolder": "Select Folder"
|
"selectFolder": "Select Folder"
|
||||||
|
},
|
||||||
|
"recentlyDeleted": {
|
||||||
|
"title": "Recently Deleted",
|
||||||
|
"noItems": "No deleted items",
|
||||||
|
"noItemsDescription": "Items you delete will appear here for 30 days before being permanently removed.",
|
||||||
|
"description": "These items will be permanently deleted after 30 days. You can restore them or delete them immediately.",
|
||||||
|
"restore": "Restore",
|
||||||
|
"deletePermanently": "Delete Permanently",
|
||||||
|
"emptyAll": "Empty All",
|
||||||
|
"daysRemaining": "{{count}} day remaining",
|
||||||
|
"daysRemaining_plural": "{{count}} days remaining",
|
||||||
|
"expiringSoon": "Expiring soon",
|
||||||
|
"confirmDeleteTitle": "Delete Permanently?",
|
||||||
|
"confirmDeleteMessage": "This item will be permanently deleted and cannot be recovered.",
|
||||||
|
"confirmEmptyAllTitle": "Empty Recently Deleted?",
|
||||||
|
"confirmEmptyAllMessage": "All {{count}} items will be permanently deleted and cannot be recovered.",
|
||||||
|
"itemRestored": "Item restored",
|
||||||
|
"itemDeleted": "Item permanently deleted",
|
||||||
|
"allItemsDeleted": "All items permanently deleted"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
@@ -350,6 +350,14 @@ class SqliteClient implements IDatabaseClient {
|
|||||||
return this.itemRepository.getRecentlyDeleted();
|
return this.itemRepository.getRecentlyDeleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of items in trash.
|
||||||
|
* @returns Number of items in trash
|
||||||
|
*/
|
||||||
|
public async getRecentlyDeletedCount(): Promise<number> {
|
||||||
|
return this.itemRepository.getRecentlyDeletedCount();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new item with its fields and related entities.
|
* Create a new item with its fields and related entities.
|
||||||
* @param item - The item to create
|
* @param item - The item to create
|
||||||
|
|||||||
@@ -115,10 +115,22 @@ export class ItemQueries {
|
|||||||
* Get all recently deleted items (in trash).
|
* Get all recently deleted items (in trash).
|
||||||
*/
|
*/
|
||||||
public static readonly GET_RECENTLY_DELETED = `
|
public static readonly GET_RECENTLY_DELETED = `
|
||||||
${ItemQueries.BASE_SELECT},
|
SELECT DISTINCT
|
||||||
|
i.Id,
|
||||||
|
i.Name,
|
||||||
|
i.ItemType,
|
||||||
|
i.FolderId,
|
||||||
|
f.Name as FolderPath,
|
||||||
|
l.FileData as Logo,
|
||||||
|
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
|
||||||
|
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
|
||||||
|
CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp,
|
||||||
|
i.CreatedAt,
|
||||||
|
i.UpdatedAt,
|
||||||
i.DeletedAt
|
i.DeletedAt
|
||||||
FROM Items i
|
FROM Items i
|
||||||
LEFT JOIN Logos l ON i.LogoId = l.Id
|
LEFT JOIN Logos l ON i.LogoId = l.Id
|
||||||
|
LEFT JOIN Folders f ON i.FolderId = f.Id
|
||||||
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL
|
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL
|
||||||
ORDER BY i.DeletedAt DESC`;
|
ORDER BY i.DeletedAt DESC`;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user