Appearance
Asset Management ​
Overview ​
Assets are the core entity in Collage Admin. The asset management system covers the full lifecycle from upload through preview, editing, versioning, bulk operations, and deletion.
- Upload — a two-step chunked upload flow: the Nitro server signs an S3 multipart request, the client uploads directly to S3, then registers the asset with the Laravel API. Upload state is managed in a batched reactive queue capped at 50 concurrent items.
- Display —
AssetCardrenders each asset as a grid tile with hover overlays, context menus, video autoplay preview, and a quick-view lightbox. It delegates all business logic to theuseAssetCardcomposable. - Bulk Actions —
DamBulkActionsBarappears when one or more assets are selected. It is a draggable floating toolbar providing download, move, copy, tag, delete, collage, and visibility actions. - Image Editing —
ImageEditorwraps Cropper.js and supports crop, rotate, flip, and resize. Each edit produces a new asset version rather than overwriting the original. - List View —
UploadedListItemrenders an asset as a table row in the uploaded assets page, with inline rename, custom fields display, tags display, and a context action menu. - Mutations —
useAssetMutationsprovides TanStack mutations for field updates and soft-deletes, invalidating both the recent-uploads dashboard cache and the trash cache on success.
Architecture ​
Upload is orchestrated entirely in AssetUpload.vue. Files are gathered from drag-and-drop or browser file picker, normalized into UploadFileItem objects, and placed in a non-reactive master list (masterFileLister). A batching function (processNextBatch) drains up to 50 items at a time into the reactive queue for rendering via UploadChunk. Each UploadChunk handles its own S3 multipart upload and fires uploaded when complete; AssetUpload then calls the Nitro signed-URL endpoint (/api/s3/get-signed-url), followed by the Laravel digital-assets/upload endpoint to register the asset. For version uploads, the endpoint switches to digital-assets/version/upload. For external guest uploads, it uses external-upload. After registration the composable emits uploaded and triggers Amplitude analytics tracking via useAmplitude. Asset queries are managed by TanStack Query via useAssetsApi and useAssetMutations; mutations invalidate the dashboard recent-uploads and overview query keys so the dashboard updates without a manual refresh.
File Structure ​
Vue Component Files ​
components/asset/AssetCard.vue— grid card for a single asset; handles video hover-play, image lazy loading, selection, context menu, quick-view dialog, share dialog, save-to-collage dialogcomponents/asset/AssetUpload.vue— drag-and-drop upload zone supporting files, folders, versioning, and external guest uploads; orchestrates the batched S3 multipart upload flowcomponents/asset/AssetImageOverlay.vue— image card overlay with selection checkbox, menu trigger, and quick-view/download shortcut buttonscomponents/asset/AssetVideoOverlay.vue— video card overlay with the same controls asAssetImageOverlay, tuned for video thumbnailscomponents/asset/ImageEditor.vue— Cropper.js-based image editing canvas; crop, rotate, flip, resize with full action historycomponents/asset/ImageEditActions.vue— toolbar above the image editor with action buttons (crop, rotate CW/CCW, flip H/V, reset, download)components/asset/UploadedListItem.vue— table row for a single asset in the uploaded-assets list; inline rename, tags chips, custom field chips, collage queue chips, context menucomponents/asset/AssetVersionItem.vue— single row in the asset version history panelcomponents/asset/UploadChunk.vue— handles S3 multipart upload for one file item; emits progress, uploaded, error, retry eventscomponents/asset/UploadChunkExternal.vue— same asUploadChunkbut for external guest upload flowcomponents/asset/UploadBackdrop.vue— modal backdrop used when the upload panel is shown in overlay modecomponents/asset/MiniUploadDialog.vue— compact upload dialog accessible from the DAM headercomponents/asset/AssetContainer.vue— container component that wraps asset grid or list view with shared statecomponents/asset/AddTags.vue— inline tag input for adding tags to an assetcomponents/asset/AssetCustomFieldsSection.vue— renders all custom fields for an asset in the detail panelcomponents/asset/MediaResources.vue— injects media preload hints for the current asset's thumbnail and video previewcomponents/dam/DamBulkActionsBar.vue— draggable floating toolbar shown when one or more assets are selected
Composable Files ​
composables/api/useAssetsApi.ts— all asset API calls:getRecentUploaded,updateWithField,deleteFile,getAssetDetails,archiveAsset,convertAsset,convertResolution,recordDownloadHistory,deleteVersion,makeCurrentVersioncomposables/api/useAssetDetailApi.ts— detail-level queries for a single asset (metadata, sharing, analytics)composables/queries/useAssetMutations.ts— TanStack mutations forupdateWithFieldanddeleteFile; invalidates dashboard and trash caches on successcomposables/queries/useFolderContentsQuery.ts— per-folder asset list with TanStack cache; used when browsing assets inside a folder
Server API Files ​
server/api/s3/multipart/create.ts— signs an S3 multipart upload initiation request server-sideserver/api/s3/multipart/complete.ts— completes a multipart upload after all parts are uploadedserver/api/s3/get-signed-url.ts— returns a signed URL for an uploaded asset after the S3 upload is doneserver/api/s3/delete-asset.ts— removes a cancelled or failed upload from S3
Type Files ​
types/asset.ts—AssetParams,AssetApiResponse,UpdateAssetPayload,DeleteFilePayload,AssetDetailsPayload,ConvertPayload,DownloadHistoryPayload,MakeVersionPayloadtypes/upload.ts—UploadFileItem,UploadFileInfo,UploadCompleteEvent,DamUploadResponsetypes/store.ts—StoreAsset— the canonical asset type used byAssetCardand the DAM store
Page Files ​
app/pages/[workspace_id]/dam/upload.vue— upload entry point; thin wrapper that renders<AssetUpload mode="both" />and applies auth + workspace middlewareapp/pages/[workspace_id]/dam/uploaded.vue— uploaded assets review page; two tabs (Uploaded / Requested), list-row layout viaUploadedListItem, deferred collage queue, marquee selection, bulk actionsapp/pages/[workspace_id]/dam/files/[_id]/index.vue— asset detail page; split-pane viewer with a 7-tab sidebar panel
Page Details ​
Upload ([workspace_id]/dam/upload.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/upload |
| Layout | collage-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended |
| Primary composable | none — page delegates entirely to <AssetUpload mode="both" /> |
This page is an intentionally thin wrapper. All upload logic (file picking, drag-and-drop, S3 multipart, progress tracking) lives in the AssetUpload component.
Uploaded Assets ([workspace_id]/dam/uploaded.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/uploaded |
| Layout | collage-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended |
| Primary composable | useUploadedPage() |
Tabs
| Tab | Content |
|---|---|
| Uploaded | List of recently uploaded assets (UploadedListItem rows) with marquee selection |
| Requested | Assets uploaded by external contributors via portal request links |
Bulk actions bar (appears when items are selected)
| Action | Permission gate |
|---|---|
| Add to Collage | canManageCollages |
| Add Tags | canManageTags |
| Custom Fields | canManageCustomFields |
| Move | canMoveAssets |
| Delete | canDeleteAssets |
Dialogs: ConfirmationDialog, AddMultipleTags, SaveToCollage, ManageCustomFields, FolderDialog
Asset Detail ([workspace_id]/dam/files/[_id]/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/files/:_id |
| Layout | collage-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended |
| Primary composable | useAssetDetail() |
The page renders in a split-pane layout: the left pane shows the asset viewer (image, video, audio, document, or generic file icon) and the right pane shows a tabbed detail panel.
Sidebar tabs
| Tab | Content | Subscription gate |
|---|---|---|
| Asset Info | Metadata, file info, download button | No |
| Tags | Tag list and tag editor | No |
| Custom Fields | Custom field values | No |
| Insights | Per-asset view analytics | Yes — Pro plan |
| Collage | Collages this asset belongs to | No |
| Version History | Version list with restore and promote actions | Yes — Pro plan |
| Image Editor | In-browser crop, resize, and filter editor | No |
Dialogs: ShareAssetDialog, ConfirmationDialog, SaveToCollage, FolderDialog, ManageCustomFields, FileConversionDialog
AssetCard ​
The primary asset display component. Switches between video (with hover-play <video>) and image (<v-img>) layouts based on the asset's file_type. Delegates all state and logic to the useAssetCard composable.
Features ​
- Video hover-play using
mouseenter/mouseleaveon the card - Lazy image loading with
fetchpriority="high"on first-visible images (isFirstImageprop) - Folder-thumbnail rendering when
file.parent_idis set - Integrated
ShareAssetDialog,SaveToCollage, andAssetQuickViewDialogmodals - Lock icon overlay for restricted-visibility assets (skipped when
flag === 'trash-listing') - Selection state via
isSelectedfromuseAssetCard
Props ​
javascript
{
mosaic: Boolean, // compact mosaic mode (no footer, no overlays)
file: StoreAsset, // the asset to display (required)
insideFolder: Boolean, // asset is being displayed inside a folder view
downloading: Boolean, // download in progress; shows spinner
searchPage: Boolean, // asset is on the search results page
flag: String, // contextual modifier: '' | 'trash-listing' | 'external'
shiftKey: Number, // shift-key state for range selection
isFirstImage: Boolean, // hints the browser to eager-load this asset
hideDownload: Boolean, // suppress the download button in overlays
canRestore: Boolean // show restore action (trash context)
}Events ​
javascript
{
quickView: [],
load: [],
assetView: [],
permissionChanged: [],
fileSelect: [],
delete: [],
restore: [],
rename: [],
permission: [],
move: [],
duplicate: [],
download: [file: Record<string, unknown>]
}Usage Example ​
vue
<script setup lang="ts">
import type { StoreAsset } from '~/types/store'
const props = defineProps<{ assets: StoreAsset[] }>()
</script>
<template>
<div class="asset-grid">
<AssetCard
v-for="(asset, index) in assets"
:key="asset.id"
:file="asset"
:is-first-image="index < 6"
flag=""
@delete="handleDelete(asset)"
@restore="handleRestore(asset)"
/>
</div>
</template>AssetUpload ​
Drag-and-drop upload zone. Handles standard file uploads, folder uploads (recursive structure preserved), asset version replacement, and the external guest upload flow. Uses a batching strategy to keep the reactive queue under 50 items while a non-reactive master list holds all pending files.
Performance Constants ​
| Constant | Value | Purpose |
|---|---|---|
MAX_FILES_PER_BATCH | 50 | Files processed per drop batch |
LARGE_UPLOAD_THRESHOLD | 500 | Above this count, thumbnails use icon fallbacks |
ACTIVE_QUEUE_SIZE | 50 | Max concurrent items in the reactive queue |
INTERNAL_LIMIT_STR | '5GB' | Per-file size cap for admin uploads |
Props ​
javascript
{
file: { id?: number } | null, // existing asset for version upload
isVersion: Boolean, // true = version-upload mode
mode: String, // 'both' | 'file' | 'folder'
flag: String // '' | 'external'
}Events ​
javascript
{
uploaded: [data: unknown], // fired per successful upload
'version-uploaded': [data: unknown],// fired on successful version upload
close: [] // user closed the upload panel
}Usage Example ​
vue
<script setup lang="ts">
function onAssetUploaded(data: unknown) {
// Refresh folder contents or dashboard after upload
}
</script>
<template>
<!-- Standard multi-file upload in a folder context -->
<AssetUpload
mode="both"
flag=""
@uploaded="onAssetUploaded"
@close="uploadPanelOpen = false"
/>
<!-- Version upload for an existing asset -->
<AssetUpload
:file="{ id: existingAsset.id }"
:is-version="true"
mode="file"
@version-uploaded="onVersionUploaded"
@close="versionDialogOpen = false"
/>
</template>DamBulkActionsBar ​
Floating draggable toolbar shown when selectedCount > 0. Inline icons for the most common actions overflow into a v-menu on smaller viewports. Drag position is managed by the useDraggableBar composable.
Props ​
javascript
{
selectedCount: Number, // number of selected items (required)
items: DamMenuItem[], // action definitions (required)
contentLoading: Boolean, // show skeleton state
skeletonCount: Number, // number of skeleton chips to show
floating: Boolean, // enable draggable floating mode
showSelectAllPrompt: Boolean, // show "select all N" prompt
totalSelectionCount: Number, // total count for the select-all prompt
selectAllLoading: Boolean, // disable select-all while loading
maxVisibleIcons: Number // cap on inline icons before overflow
}Events ​
javascript
{
'clear-selection': [], // user clicked the count chip to deselect all
'select-all': [], // user clicked the select-all prompt
action: [item: DamMenuItem] // user triggered a bulk action
}Usage Example ​
vue
<script setup lang="ts">
import type { DamMenuItem } from '~/types/dam-list'
const selectedCount = ref(0)
const bulkActions: DamMenuItem[] = [
{ key: 'download', label: 'Download', icon: 'downloadIcon' },
{ key: 'move', label: 'Move', icon: 'moveIcon' },
{ key: 'delete', label: 'Delete', icon: 'deleteIcon' },
]
function onBulkAction(item: DamMenuItem) {
if (item.key === 'delete') handleBulkDelete()
if (item.key === 'move') handleBulkMove()
}
</script>
<template>
<DamBulkActionsBar
:selected-count="selectedCount"
:items="bulkActions"
:floating="true"
@clear-selection="selectedCount = 0"
@action="onBulkAction"
/>
</template>ImageEditor ​
Cropper.js canvas for non-destructive image editing. Each exported edit is uploaded as a new asset version. Action history allows undo-chain replay. The editor reports ready/error state via events, enabling parent components to show their own loading overlays.
Props ​
javascript
{
src: String, // image URL to load in the editor (required)
alt: String, // img alt text
mimeType: String, // MIME type for export (default: 'image/png')
isCompress: Boolean, // enable compression on export
originalData: Object | null, // original width/height/mimeType for reset
originalImageData: ArrayBuffer | null,// raw bytes for lossless operations
imageDataLoading: Boolean // parent loading state for the image bytes
}Events ​
javascript
{
'cropper-ready': [ready: Boolean],
'image-error': [value: Boolean],
'update:dimensions': [dims: ImageDimensionsUpdate],
'update:transform': [state: TransformState],
'export-error': [message: String],
'img-editor-processing': [processing: Boolean],
'cropper-img-loaded': [loading: Boolean],
'img-editor-cropbox-data': [data: CropBoxData],
'is-active-crop': [],
'img-edit-download': []
}Usage Example ​
vue
<script setup lang="ts">
const imageSrc = ref('https://cdn.example.com/asset.jpg')
const isReady = ref(false)
function onCropperReady(ready: boolean) {
isReady.value = ready
}
</script>
<template>
<div class="editor-wrapper">
<ImageEditor
:src="imageSrc"
mime-type="image/jpeg"
:is-compress="true"
@cropper-ready="onCropperReady"
@img-editor-processing="showSpinner = $event"
@img-edit-download="onDownload"
/>
<ImageEditActions :disabled="!isReady" />
</div>
</template>useAssetsApi ​
API composable for asset operations. All methods are wrapped in track() from useApiRequestState.
Methods ​
javascript
{
getRecentUploaded: (params: AssetParams) => Promise<any>,
updateWithField: (payload: UpdateAssetPayload) => Promise<AssetApiResponse>,
deleteFile: (payload: DeleteFilePayload) => Promise<AssetApiResponse>,
viewAssetsCountApi: (payload: AssetDetailsPayload) => Promise<AssetApiResponse>,
getAssetDetails: (payload: AssetDetailsPayload) => Promise<AssetApiResponse>,
getAssetsAnalyticsSummary: (payload: AnalyticsPayload) => Promise<AssetApiResponse>,
archiveAsset: (payload: UpdateAssetPayload) => Promise<AssetApiResponse>,
convertAsset: (payload: ConvertPayload) => Promise<AssetApiResponse>,
convertResolution: (payload: ConvertPayload) => Promise<Blob>,
recordDownloadHistory: (payload: DownloadHistoryPayload) => Promise<AssetApiResponse>,
deleteVersion: (selectedVersion: number | string, assetId: number | string, activeVersion: number | string) => Promise<AssetApiResponse>,
makeCurrentVersion: (payload: MakeVersionPayload) => Promise<AssetApiResponse>,
isLoading: Ref<boolean>,
error: Ref<Error | null>
}useAssetMutations ​
TanStack mutations for asset writes in the uncategorized/recent-uploads page context. Folder and collage pages manage their own invalidation through scoped composables (useFolderContentsQuery, useCollageContentsQuery).
Mutations ​
javascript
{
updateAssetFieldMutation: UseMutationReturnType<AssetApiResponse, UpdateAssetPayload>,
deleteAssetMutation: UseMutationReturnType<AssetApiResponse, DeleteFilePayload>
}On updateAssetFieldMutation success: invalidates queryKeys.dashboard.recentUploads and queryKeys.dashboard.overview.
On deleteAssetMutation success: same plus queryKeys.trash.all (soft-deleted assets appear in trash).
Workflows ​
S3 Multipart Upload Flow ​
User drops file on AssetUpload drop zone
→ onFileDrop() validates file size (5 GB internal / configurable external limit)
→ File added to masterFileLister[]
→ processNextBatch() drains up to 50 items into reactive queue[]
→ UploadChunk renders for each queue item
→ UploadChunk calls POST /api/s3/multipart/create (Nitro signs request)
→ UploadChunk uploads parts directly to S3
→ UploadChunk calls POST /api/s3/multipart/complete
→ UploadChunk emits 'uploaded' with response
→ AssetUpload.onChunkUploaded():
→ GET /api/s3/get-signed-url (Nitro returns CDN URL)
→ POST digital-assets/upload (registers asset in Laravel)
→ emit('uploaded', data)
→ damStore.updateStorage() (refresh storage quota)
→ Amplitude trackActivity()
→ processNextBatch() (drain next batch from masterFileLister)Version Upload Flow ​
User opens version upload dialog for an existing asset
→ AssetUpload mounted with :is-version="true" and :file="{ id: assetId }"
→ User selects a single file
→ Same S3 multipart flow as above
→ On success, calls POST digital-assets/version/upload instead of digital-assets/upload
→ emit('version-uploaded', data)
→ Dialog closes automaticallyBulk Delete Flow ​
User selects assets → selects Delete from DamBulkActionsBar
→ emit('action', { key: 'delete' })
→ Parent calls deleteAssetMutation.mutate({ payload })
→ POST digital-assets/delete-file
→ onSuccess: invalidate recentUploads + overview + trash caches
→ Assets removed from list view
→ Assets appear in trash pageAPI Integration ​
Endpoints ​
| Endpoint | Method | Description |
|---|---|---|
/api/s3/multipart/create | POST | Server-side signs S3 multipart initiation |
/api/s3/multipart/complete | POST | Completes the multipart upload |
/api/s3/get-signed-url | GET | Returns CDN-signed URL after upload |
/api/s3/delete-asset | DELETE | Removes a failed/cancelled upload from S3 |
digital-assets/upload | POST | Registers an uploaded asset in the Laravel backend |
digital-assets/version/upload | POST | Registers a new version for an existing asset |
external-upload | POST | Registers an asset uploaded by a guest user |
digital-assets/delete-file | POST | Soft-deletes an asset (moves to trash) |
digital-assets/update-with-field | POST | Updates a single field on an asset |
digital-assets/version/make-current-version | POST | Promotes a version to current |
digital-assets/version/:id/remove | DELETE | Hard-deletes a specific version |
Upload Registration Request ​
json
{
"workspace_id": "123",
"file_name": "uuid-from-s3",
"display_file_name": "my-photo.jpg",
"display_file": "https://cdn.example.com/signed-url",
"auto_tag": 1,
"mime_type": "image/jpeg",
"file_extension": "jpg",
"file_size": 2048576,
"category_id": 45
}Upload Registration Response ​
json
{
"data": {
"id": 9821,
"category_id": 45,
"display_file_name": "my-photo.jpg",
"file_type": "jpg",
"file_size": 2048576,
"thumbnail_file": "https://cdn.example.com/thumb/my-photo.jpg"
}
}