Skip to content

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:

  1. useTrashApi — raw API calls for fetching, permanently deleting, and restoring trashed items.
  2. useTrashQuery — TanStack infinite query that paginates trashed items 36 per page, normalizing the response into typed TrashAsset[] and TrashFolder[] arrays with client-side deduplication.
  3. useTrashMutations — TanStack mutations for permanent delete and restore, each with onSuccess handlers 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 with flag="trash-listing" to suppress restricted-visibility lock icons
  • components/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 in track() for loading/error state
  • composables/queries/useTrashQuery.ts — TanStack infinite query; paginates trash items, normalizes into files and folders, deduplicates by ID across pages
  • composables/queries/useTrashMutations.ts — TanStack mutations for permanent delete and restore with cross-cache invalidation

Type Files ​

  • types/trash.ts — exports TrashAsset, 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) ​

FieldValue
Route/:workspace_id/dam/trash
Layoutcollage-layout
Middlewareauth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended
Primary composableuseTrashPage()

Page-level menu (DamItemMenu in the header) — gated by mainTrashMenu permission

ActionNotes
Empty TrashPermanently deletes all trashed items
Restore AllRestores all trashed items to their original locations

Bulk-action bar (DamBulkActionsBar) — appears when items are selected

ActionPermission gate
RestorecanRestoreTrash
DeletecanManageTrash

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' become TrashFolder with computed total_assets, sub_category_count, and total_contain fields.
  • All other items become TrashAsset with normalized uuid, display_file_name, file_size, file_type, created_at, and modified_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 load

Restore 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 DAM

Permanent 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 permanently

Auto-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 timestamp

API Integration ​

Endpoints ​

EndpointMethodDescription
digital-assets/category/get-deleted-category-with-filesGETPaginated list of soft-deleted assets and folders
digital-assets/category/restore-deleted-category-with-filesPOSTRestore selected items to their original location
digital-assets/category/permanent-delete-category-with-filesPOSTPermanently 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>