Appearance
Trash ​
Overview ​
Deleted assets and folders are soft-deleted — moved to a workspace trash bin rather than immediately destroyed. The trash system is built on three composables that separate concerns cleanly:
useTrashApi— raw API calls for fetching, permanently deleting, and restoring trashed items.useTrashQuery— TanStack infinite query that paginates trashed items 36 per page, normalizing the response into typedTrashAsset[]andTrashFolder[]arrays with client-side deduplication.useTrashMutations— TanStack mutations for permanent delete and restore, each withonSuccesshandlers that invalidate the affected query caches across the trash, folders, and dashboard slices.
The trash page is a Nuxt 4 route at /:workspace_id/dam/trash. It renders trashed assets and folders using the same AssetCard component used throughout the DAM with flag="trash-listing" to suppress the visibility lock icon. Sort order is user-controllable and drives both the query key and the API request parameters, so changing sort triggers a fresh server fetch rather than client-side re-sorting.
Architecture ​
Soft delete is initiated from asset and folder context menus, the bulk-actions bar (DamBulkActionsBar), or keyboard shortcuts throughout the DAM. The delete call goes to the Laravel backend (digital-assets/delete-file for assets, digital-assets/category/delete-category-with-files for folders), which sets a deleted_at timestamp without removing the S3 object. On success, useAssetMutations and useFolderMutations each invalidate queryKeys.trash.all in addition to their own caches, keeping the trash page fresh without a manual reload.
The trash page mounts useTrashQuery with reactive sortValue and sortBy refs. The query uses useInfiniteQuery with pageParam starting at 1; getNextPageParam returns undefined when the page count equals lastPage from the server response. The flattened files and folders computeds deduplicate by id across pages to handle the edge case where backend pagination overlaps after intermediate deletions.
Restore invalidates five query families simultaneously: trash list, all folders, dashboard recent folders, dashboard recent uploads, and the dashboard overview. This ensures that a restored asset appears immediately in its original folder and that all counter widgets on the dashboard reflect the change.
File Structure ​
Vue Component Files ​
components/asset/AssetCard.vue— renders trashed assets as grid cards withflag="trash-listing"to suppress restricted-visibility lock iconscomponents/dam/DamBulkActionsBar.vue— bulk-action toolbar shown on the trash page for multi-select restore or permanent delete
Composable Files ​
composables/api/useTrashApi.ts— raw API layer:fetchTrashItems,permanentDeleteItems,restoreTrashItems; all wrapped intrack()for loading/error statecomposables/queries/useTrashQuery.ts— TanStack infinite query; paginates trash items, normalizes intofilesandfolders, deduplicates by ID across pagescomposables/queries/useTrashMutations.ts— TanStack mutations for permanent delete and restore with cross-cache invalidation
Type Files ​
types/trash.ts— exportsTrashAsset,TrashFolder,FetchTrashParams,TrashResponse,DeleteTrashParams,RestoreTrashParams
Page Files ​
app/pages/[workspace_id]/dam/trash/index.vue— trash page; grid/list toggle, page-level action menu, bulk-action bar, infinite scroll, and 30-day auto-delete alert
Page Details ​
Trash ([workspace_id]/dam/trash/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/trash |
| Layout | collage-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended |
| Primary composable | useTrashPage() |
Page-level menu (DamItemMenu in the header) — gated by mainTrashMenu permission
| Action | Notes |
|---|---|
| Empty Trash | Permanently deletes all trashed items |
| Restore All | Restores all trashed items to their original locations |
Bulk-action bar (DamBulkActionsBar) — appears when items are selected
| Action | Permission gate |
|---|---|
| Restore | canRestoreTrash |
| Delete | canManageTrash |
A dismissible alert banner reminds users that trashed items are permanently deleted after 30 days.
The page supports grid and list view modes, column sorting, marquee drag-selection, and infinite scroll. The only dialog rendered is ConfirmationDialog (used for both restore and permanent-delete confirmations).
useTrashApi ​
Raw API composable for trash operations. The workspace ID is resolved internally from useWorkspaceId() and injected into every request — callers do not pass a workspace ID.
Methods ​
javascript
{
fetchTrashItems: (params: Partial<FetchTrashParams>) => Promise<ApiResponse<TrashResponse>>,
permanentDeleteItems: (params: DeleteTrashParams) => Promise<ApiResponse>,
restoreTrashItems: (params: RestoreTrashParams) => Promise<ApiResponse>,
isLoading: Ref<boolean>,
error: Ref<Error | null>
}FetchTrashParams Defaults ​
javascript
{
page: 1,
total_record: 36,
sort_value: 'modified_at',
sort_by: 'DESC'
}Usage Example ​
vue
<script setup lang="ts">
const { fetchTrashItems, permanentDeleteItems, restoreTrashItems, isLoading } = useTrashApi()
async function loadPage(page: number) {
const response = await fetchTrashItems({
page,
sort_value: 'modified_at',
sort_by: 'DESC',
})
return response.data
}
</script>useTrashQuery ​
Infinite TanStack Query for the trash list. Takes reactive sort options so re-sorting triggers a server refetch. Returns flattened, deduplicated files and folders computed refs.
Options ​
javascript
{
sortValue: Ref<string>, // field to sort by: 'modified_at' | 'created_at' | 'display_file_name'
sortBy: Ref<string> // direction: 'ASC' | 'DESC'
}Return Value ​
javascript
{
files: ComputedRef<TrashAsset[]>, // deduplicated across all pages
folders: ComputedRef<TrashFolder[]>, // deduplicated across all pages
isPending: Ref<boolean>,
isFetchingNextPage: Ref<boolean>,
hasNextPage: Ref<boolean>,
error: Ref<Error | null>,
fetchNextPage: () => void,
refetch: () => void
}Page Normalization ​
The response from digital-assets/category/get-deleted-category-with-files nests all items in assets_with_folder.data. Each item carries an item_type field ('folder' or a file type string). The query normalizes them into separate typed arrays:
- Items with
item_type === 'folder'becomeTrashFolderwith computedtotal_assets,sub_category_count, andtotal_containfields. - All other items become
TrashAssetwith normalizeduuid,display_file_name,file_size,file_type,created_at, andmodified_at.
Usage Example ​
The actual trash page consumes useTrashQuery indirectly through the useTrashPage() facade. Infinite scroll fires when the user reaches 85% of the container height — there is no Load More button.
vue
<script setup lang="ts">
const sortValue = ref('modified_at')
const sortBy = ref('DESC')
const {
files,
folders,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useTrashQuery({ sortValue, sortBy })
const loadMore = computed(() => isFetchingNextPage.value)
async function fetchTrashItemsData() {
if (hasNextPage.value && !isPending.value && !loadMore.value) {
await fetchNextPage()
}
}
async function handleScroll(event: Event) {
try {
const container = event.target as HTMLElement
if (!container) return
const { scrollTop, scrollHeight, clientHeight } = container
const hasItems = Boolean(folders.value.length || files.value.length)
if (!hasItems) return
const nearBottom = (scrollTop + clientHeight) / scrollHeight >= 0.85
if (nearBottom) {
await fetchTrashItemsData()
}
} catch (_) {}
}
</script>
<template>
<div class="customscrollbar" @scroll="handleScroll">
<div v-if="isPending" />
<template v-else>
<div v-for="folder in folders" :key="folder.id">{{ folder.folder_name }}</div>
<div v-for="file in files" :key="file.id">{{ file.display_file_name }}</div>
</template>
</div>
</template>useTrashMutations ​
TanStack mutations for trash write operations. Both mutations invalidate the trash query on success. The restore mutation additionally invalidates folders and dashboard caches.
Mutations ​
javascript
{
permanentDeleteMutation: UseMutationReturnType<ApiResponse, DeleteTrashParams>,
restoreMutation: UseMutationReturnType<ApiResponse, RestoreTrashParams>
}Cache Invalidation on Restore ​
javascript
Promise.all([
invalidateTrash(), // queryKeys.trash.all
queryClient.invalidateQueries({ queryKey: queryKeys.folders.all }),
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.recentFolders(workspaceId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.recentUploads(workspaceId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.overview(workspaceId) }),
])Usage Example ​
vue
<script setup lang="ts">
import type { DeleteTrashParams, RestoreTrashParams } from '~/types/trash'
const { permanentDeleteMutation, restoreMutation } = useTrashMutations()
function handlePermanentDelete(ids: number[], folders: number[]) {
permanentDeleteMutation.mutate({
file_ids: ids,
category_ids: folders,
} as DeleteTrashParams)
}
function handleRestore(ids: number[], folders: number[]) {
restoreMutation.mutate({
file_ids: ids,
category_ids: folders,
} as RestoreTrashParams)
}
</script>
<template>
<div class="trash-actions">
<v-btn
:loading="restoreMutation.isPending.value"
@click="handleRestore(selectedFileIds, selectedFolderIds)"
>
Restore
</v-btn>
<v-btn
color="error"
:loading="permanentDeleteMutation.isPending.value"
@click="handlePermanentDelete(selectedFileIds, selectedFolderIds)"
>
Delete Permanently
</v-btn>
</div>
</template>Workflows ​
Soft Delete Flow ​
User clicks Delete on an asset (context menu, bulk action, keyboard)
→ useAssetMutations.deleteAssetMutation.mutate(payload)
→ POST digital-assets/delete-file
→ Backend sets deleted_at on the asset record (S3 object untouched)
→ onSuccess:
→ invalidate queryKeys.dashboard.recentUploads
→ invalidate queryKeys.dashboard.overview
→ invalidate queryKeys.trash.all
→ Asset disappears from DAM views
→ Asset appears on next trash page loadRestore Flow ​
User selects trashed items → clicks Restore
→ restoreMutation.mutate({ file_ids, category_ids })
→ POST digital-assets/category/restore-deleted-category-with-files
→ Backend clears deleted_at; places items in their original folder
(or workspace root if original folder was also deleted)
→ onSuccess:
→ invalidate trash.all
→ invalidate folders.all
→ invalidate dashboard.recentFolders
→ invalidate dashboard.recentUploads
→ invalidate dashboard.overview
→ Items removed from trash list
→ Items visible in their restored location in the DAMPermanent Delete Flow ​
User selects trashed items → clicks Delete Permanently
→ permanentDeleteMutation.mutate({ file_ids, category_ids })
→ POST digital-assets/category/permanent-delete-category-with-files
→ Backend hard-deletes records and removes S3 objects
→ onSuccess:
→ invalidate trash.all
→ Items removed from trash list permanentlyAuto-Purge Flow (backend-scheduled) ​
Scheduled backend job runs daily
→ Queries all items where deleted_at < NOW() - 30 days
→ Permanently removes S3 objects and database records
→ Frontend displays "Deletes in X days" label per item using deleted_at timestampAPI Integration ​
Endpoints ​
| Endpoint | Method | Description |
|---|---|---|
digital-assets/category/get-deleted-category-with-files | GET | Paginated list of soft-deleted assets and folders |
digital-assets/category/restore-deleted-category-with-files | POST | Restore selected items to their original location |
digital-assets/category/permanent-delete-category-with-files | POST | Permanently delete selected items from S3 and database |
Fetch Trash Request Parameters ​
json
{
"workspace_id": "123",
"page": 1,
"total_record": 36,
"sort_value": "modified_at",
"sort_by": "DESC"
}Fetch Trash Response ​
json
{
"data": {
"assets_with_folder": {
"data": [
{
"id": 4512,
"item_type": "jpg",
"uuid": "a1b2c3d4",
"display_file_name": "hero-banner.jpg",
"file_size": 1048576,
"file_type": "jpg",
"created_at": "2025-07-10T14:23:00Z",
"modified_at": "2025-08-01T09:00:00Z",
"deleted_at": "2025-08-01T09:00:00Z"
},
{
"id": 210,
"item_type": "folder",
"folder_name": "Campaign Assets",
"assets_count": 14,
"sub_categories_count": 2,
"deleted_at": "2025-07-28T11:30:00Z"
}
],
"last_page": 3
}
}
}Restore Request ​
json
{
"workspace_id": "123",
"file_ids": [4512, 4513],
"category_ids": [210]
}Permanent Delete Request ​
json
{
"workspace_id": "123",
"file_ids": [4512],
"category_ids": []
}Component Integration ​
The trash page delegates all state and logic to the useTrashPage() facade composable. The page template handles two view modes (grid and list) driven by the mode ref returned from useTrashPage().
vue
<!-- pages/[workspace_id]/dam/trash/index.vue (simplified) -->
<script setup lang="ts">
import type { DamColumn, DamMenuItem, FolderAssetItem } from '~/types/dam-list'
import type { TrashAsset, TrashFolder } from '~/types/trash'
definePageMeta({
layout: 'collage-layout',
middleware: ['auth-check', 'check-workspace', 'check-workspace-access', 'can-access-dam-module', 'check-if-suspended'],
})
const {
canManageTrash,
canRestoreTrash,
files,
folders,
mode,
loading,
contentLoading,
loadMore,
deleting,
restoring,
selectedCount,
totalAssets,
fileCardKey,
page_sort_value,
page_sort_by,
delDialogHeader,
delDialogMsg,
getDelDialog,
mainTrashMenu,
handleSort,
handleScroll,
toggleSelect,
setItemSelected,
toggleSelectAll,
showConfirmationDialog,
closeDelDialog,
submitDelDialog,
selectionClick,
changeView,
handleRemoveSelection,
} = useTrashPage()
// Bulk action bar items — gated by role capabilities
const bulkActionItems = computed<DamMenuItem[]>(() => {
const items: DamMenuItem[] = []
if (canRestoreTrash.value) {
items.push({ key: 'restore', icon: 'restoreIcon', label: restoring.value ? 'Restoring...' : 'Restore', loading: restoring.value })
}
if (canManageTrash.value) {
items.push({ key: 'delete', icon: 'deleteIcon', label: deleting.value ? 'Deleting...' : 'Delete', loading: deleting.value })
}
return items
})
function onBulkActionClick(item: DamMenuItem) {
if (item.key === 'restore') selectionClick('restore')
else if (item.key === 'delete') selectionClick('delete')
}
function getTrashFolder(index: number): TrashFolder { return folders.value[index]! }
function getTrashFileByCombinedIndex(index: number): TrashAsset { return files.value[index - folders.value.length]! }
</script>
<template>
<div class="collage-body flex-column">
<!-- Header: title + bulk actions bar -->
<v-row no-gutters class="flex-0-0 pb-4">
<v-col cols="12" md="8">
<h1 v-if="!contentLoading" class="page-title">Trash</h1>
<v-skeleton-loader v-else type="heading" />
</v-col>
<v-col cols="12" md="4">
<DamBulkActionsBar
:selected-count="selectedCount"
:items="bulkActionItems"
:content-loading="contentLoading"
@action="onBulkActionClick"
@clear-selection="handleRemoveSelection"
/>
</v-col>
</v-row>
<!-- 30-day warning banner -->
<EmailNotificationsAlert
v-if="!contentLoading && totalAssets > 0"
color="secondary no-icon"
message="Assets in trash are deleted forever after 30 days."
/>
<v-card class="boxview">
<!-- View mode toggle -->
<v-card-title class="justify-end">
<ViewModeSwitcher
:model-value="mode"
:modes="['list', 'grid']"
@update:model-value="(m) => changeView(m === 'grid' ? 'grid' : 'list')"
/>
</v-card-title>
<!-- List view -->
<v-card-text v-if="mode === 'list'">
<DamListViewSkeleton v-if="loading" mode="full" />
<DamLibraryListView
v-else-if="totalAssets !== 0"
:items="trashItems"
:sort-field="page_sort_value"
:sort-dir="page_sort_by.toLowerCase()"
:show-checkbox="true"
:resolve-menu-items="resolveTrashMenuItems"
@sort-change="(f) => handleSort(f)"
@select-toggle="onSelectToggle"
@menu-click="onMenuClick"
@scroll="(e) => handleScroll('main', e)"
/>
<div v-else class="no-data"><EmptyStateIcon /><h5>Your trash is empty.</h5></div>
</v-card-text>
<!-- Grid view: folders first, then files -->
<v-card-text v-if="mode === 'grid'">
<div v-if="contentLoading">
<CardSkeleton :total-rows="18" flag="card-loading" />
</div>
<v-row v-else no-gutters class="grid-lists">
<v-col v-for="(_, index) in totalAssets" :key="index" cols="12" class="grid-item">
<folder-card
v-if="folders.length > index"
:folder="getTrashFolder(index)"
:can-restore="canRestoreTrash"
flag="trash-listing"
:shift-key="fileCardKey"
@restore="showConfirmationDialog('restore-selected', [getTrashFolder(index)], 'folder')"
@delete="showConfirmationDialog('delete-selected', [getTrashFolder(index)], 'folder')"
/>
<asset-card
v-else
:file="getTrashFileByCombinedIndex(index)"
:can-restore="canRestoreTrash"
flag="trash-listing"
:shift-key="fileCardKey"
@restore="showConfirmationDialog('restore-selected', [getTrashFileByCombinedIndex(index)], 'file')"
@delete="showConfirmationDialog('delete-selected', [getTrashFileByCombinedIndex(index)], 'file')"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Restore / delete confirmation dialog -->
<confirmation-dialog
:heading="delDialogHeader"
:msg="delDialogMsg"
:dialog="getDelDialog"
@confirm="submitDelDialog()"
@cancel="closeDelDialog()"
/>
</div>
</template>