mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-19 15:39:13 -05:00
Add deleted items scaffolding logic (#1404)
This commit is contained in:
@@ -65,6 +65,14 @@ export default function ItemsLayout(): React.ReactNode {
|
||||
title: t('items.emailPreview'),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="deleted"
|
||||
options={{
|
||||
title: t('items.recentlyDeleted.title'),
|
||||
headerBackTitle: t('items.title'),
|
||||
...defaultHeaderOptions,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
405
apps/mobile-app/app/(tabs)/items/deleted.tsx
Normal file
405
apps/mobile-app/app/(tabs)/items/deleted.tsx
Normal file
@@ -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 [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
|
||||
// Recently deleted count state
|
||||
const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0);
|
||||
|
||||
const authContext = useApp();
|
||||
const dbContext = useDb();
|
||||
|
||||
@@ -197,16 +200,18 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
}, [itemsList, searchQuery, filterType]);
|
||||
|
||||
/**
|
||||
* Load items (credentials) and folders.
|
||||
* Load items (credentials), folders, and recently deleted count.
|
||||
*/
|
||||
const loadItems = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const [items, loadedFolders] = await Promise.all([
|
||||
const [items, loadedFolders, deletedCount] = await Promise.all([
|
||||
dbContext.sqliteClient!.getAllItems(),
|
||||
dbContext.sqliteClient!.getAllFolders()
|
||||
dbContext.sqliteClient!.getAllFolders(),
|
||||
dbContext.sqliteClient!.getRecentlyDeletedCount()
|
||||
]);
|
||||
setItemsList(items);
|
||||
setFolders(loadedFolders);
|
||||
setRecentlyDeletedCount(deletedCount);
|
||||
setIsLoadingItems(false);
|
||||
} catch (err) {
|
||||
Toast.show({
|
||||
@@ -474,6 +479,16 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
height: 1,
|
||||
marginVertical: 4,
|
||||
},
|
||||
filterMenuItemWithBadge: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
filterMenuItemBadge: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
// Folder pills styles
|
||||
folderPillsContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -682,6 +697,28 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
{t('common.attachments')}
|
||||
</ThemedText>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
169
apps/mobile-app/components/items/ConfirmDeleteModal.tsx
Normal file
169
apps/mobile-app/components/items/ConfirmDeleteModal.tsx
Normal file
@@ -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.",
|
||||
"noFolder": "No 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": {
|
||||
|
||||
@@ -350,6 +350,14 @@ class SqliteClient implements IDatabaseClient {
|
||||
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.
|
||||
* @param item - The item to create
|
||||
|
||||
@@ -115,10 +115,22 @@ export class ItemQueries {
|
||||
* Get all recently deleted items (in trash).
|
||||
*/
|
||||
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
|
||||
FROM Items i
|
||||
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
|
||||
ORDER BY i.DeletedAt DESC`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user