Appearance
Folder Management ​
Overview ​
Folders organize assets into a named hierarchy and are always visible in the left panel of the DAM shell. The folder system is composed of four composables with distinct responsibilities:
useFolderApi— raw API layer covering the full CRUD surface: create, rename, delete, fetch flat list, fetch sub-folders, fetch folder contents, move files, copy files, and update asset fields within a folder.useFoldersQuery— TanStack query for the flat top-level folder list. Normalizes eachFolderResponseinto a typedFolderwith sensible defaults. Also queries the workspace overview counters alongside the folder list.useFolderMutations— TanStack mutations for delete and rename. Rename uses an optimistic cache patch (patchFolderName) that updates the folder list and the dashboard recent-folders widget immediately, without waiting for a refetch.useFolderContentsQuery— per-folder asset list query. UsesqueryClient.fetchQueryrather thanuseQueryso parents control when a fetch fires; exposesinvalidateContentsfor targeted post-mutation cache busting.
The dam.ts Pinia store (useDamStore) holds the legacy folder list (folderList), loading flags (foldersLoading), and the currently open folder (currentFolder). New code should prefer TanStack Query via useFoldersQuery over the store's folder state.
Architecture ​
The folder tree in the left panel is driven by useDamStore.folderList. Sub-folder expansion triggers useFolderApi.getSubFolder to lazy-load children. The store's currentFolder is set on navigation so breadcrumbs and the folder detail header always reflect the open folder without an additional API call. CRUD operations go through useFolderMutations (for rename and delete) or direct useFolderApi calls (for create, move, copy) followed by manual cache invalidation via useFoldersQuery.invalidateFoldersList. Folder creation uses two different endpoints depending on depth: digital-assets/category/add for root folders, digital-assets/category/add-sub-category for sub-folders. The addFolder method in useFolderApi selects the correct endpoint based on whether body.category_id is non-zero.
File Structure ​
Vue Component Files ​
components/dam/DamLibraryListView.vue— left-panel folder tree; renders the hierarchical folder list with expand/collapse, context menu, and drag-and-dropcomponents/dam/DamDetailListView.vue— folder detail view; lists assets and sub-folders within a selected foldercomponents/dam/DamListHeader.vue— header bar above the folder contents; shows folder name, sort controls, view-toggle, and upload buttoncomponents/dam/DamDisplayPanel.vue— right-side detail panel; shows folder or asset metadata, permissions, and sharing optionscomponents/dam/DamItemMenu.vue— context menu for a folder item: rename, new sub-folder, move, copy, delete, sharecomponents/dam/DamBulkActionsBar.vue— bulk-action toolbar for multi-select folder and asset operationscomponents/dam/SearchBreadCrumbs.vue— breadcrumb trail showing the path from workspace root to the current foldercomponents/dam/DamListViewSkeleton.vue— skeleton loading state for the folder list
Composable Files ​
composables/api/useFolderApi.ts— full folder API surface; all methods aretrack()-wrapped for loading/error statecomposables/queries/useFoldersQuery.ts— TanStack query for the flat top-level folder list plus workspace overviewcomposables/queries/useFolderMutations.ts— TanStack mutations for rename (with optimistic patch) and delete (with trash and folder cache invalidation)composables/queries/useFolderContentsQuery.ts— per-folder content query viafetchQuery; exposes targetedinvalidateContents
Page Files ​
pages/[workspace_id]/dam/folders/index.vue— workspace folder list; grid/list toggle, create folder button, inline sort, infinite scroll, per-row context menupages/[workspace_id]/dam/folders/[_id]/index.vue— folder detail; displays sub-folders and assets, in-folder Typesense search, filter chips, bulk-action bar, marquee selection, grid/list view with thumbnail-size control
Store Files ​
stores/dam.ts— Pinia store (useDamStore); holdsfolderList,foldersLoading,currentFolder, workspace storage, DAM instance settings
Type Files ​
types/folder.ts— exportsFolder,FolderResponse,FolderParams,DeleteFolderParams,RenameFolderData,MoveFilesPayload,AddFolderBody,CopyFileBody,MoveMultipleBody
useFolderApi ​
Raw API composable for all folder operations. The workspace ID is resolved from useWorkspaceId() internally. All methods are wrapped in track() from useApiRequestState.
Methods ​
javascript
{
getRecentsFolders: () => Promise<Folder[]>,
createFolder: (name: string) => Promise<ApiResponse<Folder>>,
deleteFolder: (params: DeleteFolderParams) => Promise<ApiResponse>,
fetchFolders: (params: FolderParams) => Promise<ApiResponse<Folder[]>>,
fetchAssetsWithThrottle: (folderId: number) => Promise<ApiResponse<{ total_assets: number; total_category: number }>>,
renameFolder: (folderId: number, data: RenameFolderData) => Promise<ApiResponse<Folder>>,
fetchFolderCount: (folderId: number, workspace_id: string | number) => Promise<ApiResponse<{ total_assets: number; total_category: number }>>,
deleteFolderApi: (folderId: number, workspace_id: string | number) => Promise<ApiResponse>,
fetchFolderContent: (params: FolderParams) => Promise<ApiResponse<Folder[]>>,
fetchAssetCount: (folderId: number, workspace_id: string | number) => Promise<ApiResponse<{ total_assets: number; total_category: number }>>,
renameCategory: (id: number, data: RenameFolderData) => Promise<ApiResponse<Folder>>,
moveFilesToCategory: (payload: MoveFilesPayload) => Promise<ApiResponse>,
getSubFolder: (workspace_id: string | number, category_id: number) => Promise<ApiResponse<Folder[]>>,
addFolder: (body: AddFolderBody) => Promise<ApiResponse<Folder>>,
copyFile: (body: CopyFileBody) => Promise<ApiResponse>,
moveMultiple: (body: MoveMultipleBody) => Promise<ApiResponse>,
updateAssetField: (data: { workspace_id: string | number; digital_assets_id: number; field_name: string; field_value: string }) => Promise<ApiResponse>,
updateCustomFields: (data: { workspace_id: string | number; updated_data: unknown[]; asset_ids: number[] }) => Promise<ApiResponse>,
searchCollection: (data: { request: { collection: string; id: number }; workspace_id: string | number }) => Promise<ApiResponse>,
isLoading: Ref<boolean>,
error: Ref<Error | null>
}Endpoint Selection for addFolder ​
javascript
// Root folder: category_id === 0
POST digital-assets/category/add
// Sub-folder: category_id > 0
POST digital-assets/category/add-sub-category (body.sub_category_id = 0)Usage Example ​
vue
<script setup lang="ts">
const { addFolder, renameFolder, deleteFolder, getSubFolder, isLoading } = useFolderApi()
async function createSubFolder(parentId: number, name: string) {
const result = await addFolder({
workspace_id: workspaceId,
folder_name: name,
category_id: parentId,
})
return result.data
}
async function loadChildren(folderId: number) {
const result = await getSubFolder(workspaceId, folderId)
return result.data
}
</script>useFoldersQuery ​
TanStack query for the workspace folder list. Normalizes raw FolderResponse objects from the API into typed Folder objects with isLoading: true as initial state — a per-folder loading flag used by the folder tree while fetchAssetsWithThrottle populates accurate counts asynchronously.
Options ​
javascript
{
sortValue: Ref<string>, // sort field: 'folder_name' | 'modified_at' | 'created_at'
sortBy: Ref<string> // direction: 'ASC' | 'DESC'
}Return Value ​
javascript
{
foldersQuery: UseQueryReturnType<Folder[]>,
overviewQuery: UseQueryReturnType<OverviewData | undefined>,
invalidateFoldersList: () => Promise<void>,
refetchOverview: () => void
}Folder Normalization ​
Each raw FolderResponse from the API is mapped to a Folder with these computed defaults:
javascript
{
id: obj.id,
folder_name: obj.folder_name,
description: obj.description,
total_assets: obj.total_assets ?? 0,
total_category: obj.total_category ?? 0,
visibility: obj.visibility,
instance_visibility: obj.instance_visibility,
permission: obj.permission ?? null,
modified_at: obj.modified_at,
created_at: obj.created_at,
is_selected: false,
is_downloading: false,
isLoading: true, // placeholder until fetchAssetsWithThrottle resolves
parent_id: obj.parent_id ?? null,
parent_folder_id: obj.parent_folder_id,
sub_category_exist: obj.sub_category_exist ?? false,
sub_category_count: obj.sub_category_count ?? 0
}Usage Example ​
vue
<script setup lang="ts">
const sortValue = ref('folder_name')
const sortBy = ref('ASC')
const {
foldersQuery,
overviewQuery,
invalidateFoldersList,
} = useFoldersQuery({ sortValue, sortBy })
</script>
<template>
<div>
<p>{{ overviewQuery.data.value?.total_folders }} folders total</p>
<div v-if="foldersQuery.isPending.value">
<DamListViewSkeleton />
</div>
<div v-else class="folder-list">
<div
v-for="folder in foldersQuery.data.value"
:key="folder.id"
class="folder-row"
>
{{ folder.folder_name }}
</div>
</div>
</div>
</template>useFolderMutations ​
TanStack mutations for folder delete and rename. Rename applies an optimistic cache patch that updates the folder list and dashboard recent-folders widget synchronously before any refetch.
Mutations ​
javascript
{
deleteFoldersMutation: UseMutationReturnType<ApiResponse, DeleteFolderParams>,
renameFolderMutation: UseMutationReturnType<ApiResponse<Folder>, RenameFolderVariables>,
patchFolderName: (folderId: number, data: RenameFolderData) => void
}Optimistic Rename Patch ​
patchFolderName synchronously updates two cache entries without triggering a network refetch:
queryKeys.folders.list(workspaceId)— the folder list pagequeryKeys.dashboard.recentFolders(workspaceId)— the dashboard recent-folders widget
After patching it marks both keys as stale (refetchType: 'none'), guaranteeing fresh data on the next window-focus or page mount even if the cache entry was not present in this session.
Cache Invalidation on Delete ​
javascript
Promise.all([
invalidateFolderCaches(), // folders.all + dashboard.recentFolders
queryClient.invalidateQueries({ queryKey: queryKeys.trash.all }),
])The trash cache is invalidated because a deleted folder moves into trash; without this, a user opening the trash page within the 30-second global staleTime would see a stale list.
Usage Example ​
vue
<script setup lang="ts">
import type { DeleteFolderParams, RenameFolderData } from '~/types/folder'
const { deleteFoldersMutation, renameFolderMutation } = useFolderMutations()
function onRename(folderId: number, newName: string) {
renameFolderMutation.mutate({
folderId,
data: { folder_name: newName },
})
}
function onDelete(params: DeleteFolderParams) {
deleteFoldersMutation.mutate(params)
}
</script>
<template>
<div class="folder-actions">
<v-btn
:loading="renameFolderMutation.isPending.value"
@click="onRename(folder.id, newName)"
>
Rename
</v-btn>
<v-btn
color="error"
:loading="deleteFoldersMutation.isPending.value"
@click="onDelete({ category_id: folder.id })"
>
Delete
</v-btn>
</div>
</template>useFolderContentsQuery ​
Per-folder asset/sub-folder content query. Uses fetchQuery so the parent controls when a fetch fires (e.g., on expand or navigation) rather than triggering automatically on mount. Cache keys include workspace ID, folder ID, sort field, sort direction, and page number so each combination is stored independently.
Methods ​
javascript
{
fetchPage: (params: FolderParams) => Promise<ApiResponse<Folder[]>>,
invalidateContents: (workspaceId: number | string, folderId: number | string) => Promise<void>
}invalidateContents prefix-matches every sort/page combination cached for a folder via the queryKeys.folders.contents(workspaceId, folderId) prefix, so a single call busts all paginated cache entries for that folder.
Usage Example ​
vue
<script setup lang="ts">
const { fetchPage, invalidateContents } = useFolderContentsQuery()
const workspaceId = getWorkspaceId()
async function loadFolderPage(folderId: number, page: number) {
const response = await fetchPage({
workspace_id: workspaceId,
category_id: folderId,
sort_value: 'modified_at',
sort_by: 'DESC',
page,
})
return response.data
}
async function afterMoveToFolder(folderId: number) {
await invalidateContents(workspaceId, folderId)
}
</script>Workflows ​
Create Folder Flow ​
User clicks "New Folder" in the folder panel
→ Dialog opens with folder name input
→ User submits
→ useFolderApi.addFolder({ workspace_id, folder_name, category_id: 0 })
→ POST digital-assets/category/add (root folder)
OR
→ POST digital-assets/category/add-sub-category (sub-folder)
→ On success:
→ invalidateFoldersList()
→ Folder list refetches and new folder appears in treeRename Folder Flow ​
User double-clicks folder name (inline edit) or selects Rename from context menu
→ Inline text field activates
→ User edits name and presses Enter or clicks away
→ renameFolderMutation.mutate({ folderId, data: { folder_name: newName } })
→ POST digital-assets/category/rename/:folderId
→ onSuccess: patchFolderName(folderId, data)
→ Synchronously update queryKeys.folders.list cache
→ Synchronously update queryKeys.dashboard.recentFolders cache
→ Mark both as stale (refetchType: 'none')
→ UI updates immediately, no loading flashMove Files to Folder Flow ​
User selects assets → drags to a folder OR uses Move dialog
→ useFolderApi.moveMultiple({ file_ids, folder_ids, target_category_id })
→ POST digital-assets/multiple-file-folder-move
→ On success:
→ invalidateContents(workspaceId, sourceFolderId)
→ invalidateContents(workspaceId, targetFolderId)
→ Files disappear from source folder view
→ Files appear in target folder on next loadSub-Folder Lazy Load Flow ​
User expands a folder node in the tree
→ useFolderApi.getSubFolder(workspace_id, category_id)
→ GET digital-assets/category/sub-category-list?workspace_id=...&category_id=...
→ Sub-folders returned as Folder[]
→ Tree node expands and renders children
→ Each child's asset count fetched via fetchAssetsWithThrottle()
→ POST digital-assets/category/get-count/:folderId
→ Updates folder.isLoading → false and folder.total_assetsDelete Folder Flow ​
User selects Delete from folder context menu
→ ConfirmDialog shown
→ User confirms
→ deleteFoldersMutation.mutate({ category_id: folder.id })
→ POST digital-assets/category/delete-category-with-files
→ Backend soft-deletes folder and all contents
→ onSuccess:
→ invalidate folders.all
→ invalidate dashboard.recentFolders
→ invalidate trash.all
→ Folder disappears from tree
→ Folder and its assets appear in trashAPI Integration ​
Endpoints ​
| Endpoint | Method | Description |
|---|---|---|
digital-assets/category/all-category-list | GET | Fetch all top-level folders for the workspace |
digital-assets/category/add | POST | Create a new root folder |
digital-assets/category/add-sub-category | POST | Create a sub-folder inside an existing folder |
digital-assets/category/rename/:id | POST | Rename a folder |
digital-assets/category/delete-category-with-files | POST | Soft-delete a folder and all its contents |
digital-assets/folder/delete/:id | DELETE | Hard-delete a folder by ID |
digital-assets/category/sub-category-list | GET | List sub-folders of a folder |
digital-assets/category/view-files-with-category | GET | List assets and sub-folders within a folder |
digital-assets/category/get-count/:id | POST | Get total asset and sub-folder counts for a folder |
digital-assets/multiple-file-move-to-folder | POST | Move multiple files to a folder |
digital-assets/multiple-file-copy-to-multiple-folder | POST | Copy files to multiple target folders |
digital-assets/multiple-file-folder-move | POST | Move files and folders to a target folder |
digital-assets/new-dashboard/recently-folders | GET | Recently accessed folders (dashboard widget) |
Create Root Folder Request ​
json
{
"workspace_id": "123",
"folder_name": "Campaign Assets"
}Create Root Folder Response ​
json
{
"data": {
"id": 215,
"folder_name": "Campaign Assets",
"description": null,
"total_assets": 0,
"total_category": 0,
"visibility": "workspace",
"created_at": "2025-08-10T12:00:00Z",
"modified_at": "2025-08-10T12:00:00Z"
}
}Rename Folder Request ​
json
{
"folder_name": "Q3 Campaign Assets",
"description": "All assets for Q3 campaigns"
}Fetch Folder List Response (normalized) ​
json
{
"data": [
{
"id": 215,
"folder_name": "Q3 Campaign Assets",
"description": "All assets for Q3 campaigns",
"total_assets": 142,
"total_category": 5,
"visibility": "workspace",
"instance_visibility": "workspace",
"permission": null,
"sub_category_exist": true,
"sub_category_count": 5,
"parent_id": null,
"modified_at": "2025-08-10T12:00:00Z",
"created_at": "2025-08-10T12:00:00Z"
}
]
}Page Details ​
Folder List — pages/[workspace_id]/dam/folders/index.vue ​
Route: /:workspace_id/dam/folders
Layout: collage-layout
Middleware: auth-check, check-workspace, can-access-dam-module, check-if-suspended
All data fetching, selection state, sort, and dialog control is delegated to useFolders(). Permission checks and per-row context menu items come from useFolderMenu().
Features ​
- "New Folder" button — shown only when
canCreateFoldersComputedis true; callsaddFolderDialog()which opens aCreateOrRenameDialogin create mode. - View mode toggle —
ViewModeSwitcherswitches between grid (FolderCard) and list (DamLibraryListView). - Grid view —
FolderCardper folder; asset/subfolder counts are hidden whilefolder.isLoadingis true (populated byfetchAssetsWithThrottlerunning in the background). The "Uncategorised" folder (is_uncategorised: true) is distinguished by its own display string and routes to/:workspace_id/dam/uploadedon click. - List view —
DamLibraryListViewwith five columns: Name, Description, Date Modified, Date Added, and a#summary column that formats as "X Subfolder(s), Y Asset(s)" or "Empty Folder". - Infinite scroll —
handleScrolllistens on thescrollContainerref; additional pages are loaded as the user scrolls. While loading more,CardSkeletonrows appear below the existing list. - Empty state — "You don't have any folders yet. Let's fix that." shown in both grid and list modes; includes a "Create your first folder" button gated by
canCreateFoldersComputed. - Sort — list view sort is handled by
onFolderSortChange, which setspage_sort_byand callssortFolders; grid view sort is controlled by the samepage_sort_bystate.
Columns (list mode) ​
| Field | Label | Notes |
|---|---|---|
folder_name | Name | Sortable |
description | Description | Min-width 250px, tooltip on overflow, not sortable |
modified_at | Date Modified | Min-width 150px, formatted as date |
created_at | Date Added | Min-width 150px, formatted as date |
_summary_display | # | Min-width 200px; "X Subfolder(s), Y Asset(s)" / "X Asset(s)" (uncategorised) / "Empty Folder" |
Context Menu (per row) ​
Resolved by resolveFolderMenu(folder) from useFolderMenu(). Dispatches to folderActions.toggleFolderDialog or openMoveFolder.
| Action | Dialog |
|---|---|
| Share | ShareAssetDialog |
| Download | downloadFolder() |
| Rename | CreateOrRenameDialog (update mode) |
| Permission | PermissionDialog |
| Move | FolderDialog (move-folder) |
| Delete | ConfirmationDialog |
Folder Detail — pages/[workspace_id]/dam/folders/[_id]/index.vue ​
Route: /:workspace_id/dam/folders/:_id
Layout: collage-layout with embedsHeader: true
Middleware: auth-check, check-workspace, can-access-dam-module, check-if-suspended
The most complex page in the DAM. All core folder data, paginated asset/subfolder list, selection state, and dialog orchestration come from useFolderDetail(). The page adds Typesense search/filter wiring, bulk-action bar, marquee selection, and viewport-fill auto-loading on top.
Header area ​
- Breadcrumbs —
BreadCrumbswith parent "Folders" and the current folder path. - Folder title —
<h1>with a tooltip for long names. Visibility icon: globe (available) or lock (restricted), driven byisVisible(folder.instance_visibility). - Title menu —
DamItemMenurendered frommainFolderMenu(fromuseFolderDetail); items include Add Subfolder, Share, Download, Rename, Portals, Move, Delete. The Portals item is disabled with an upgrade tooltip whencanUpdatePermissionis false. - Description — shown when
folder.descriptionis non-empty; truncated at 250 characters with a "Show more / Show less" toggle.
Search & filter bar ​
- In-folder search —
useSearchBarinput with placeholder "Search in {folder name}". Active whenbar.debouncedQueryis non-empty or a filter chip is applied. Shows a result count badge when active. Hidden whencanUseInnerSearchis false for the workspace. - Filter chips —
SearchFilter+AddedFilterOptionviauseSearchFilter({ context: 'folder' }). Asset-specific filters (tags, file type, uploaded-by, etc.) automatically exclude the folder collection from the Typesense search. - Display panel —
DamDisplayPanelfor sort field/direction, view mode (grid/list), and thumbnail size (grid only).
View modes ​
| Mode | Subfolder rendering | Asset rendering |
|---|---|---|
| Grid | FolderCard | AssetCard |
| List | DamDetailListView with unified rows | Same table; parent_id truthy = folder row |
Grid thumbnail size is persisted via useThumbnailSize('folders').
Data sources ​
The page unifies two data sources behind unifiedFolders / unifiedAssets computed refs:
- Browse mode (
isSearchActive = false) — data comes fromdisplayFiles(fromuseFolderDetail), split byparent_idinto folders and assets. Paginated viafetchFolder(), infinite-scroll triggered at 85% scroll depth. - Search mode (
isSearchActive = true) — data comes from Typesense multi-search (useSearchApi.getSearchResults).COMBINED_PER_PAGE = 36; folders always precede assets in the unified page. Each "load more" scrolls the folder page forward and windows the matching asset slice independently.
Search pagination logic ​
Typesense hits for folders and assets are paginated independently. On load more:
- Fetch next folder page.
- Calculate how many asset slots remain in the combined window (
COMBINED_PER_PAGE − new folders). - Fetch the asset window by computing the page(s) that cover the current
searchAssetOffset, then slice to the exact needed count.
When asset-specific filters are active, includeCategoriesInSearch is false and only digital_assets is queried.
Selection ​
- Checkbox click — single-item toggle via
onItemSelect; updatesselectedItemsandis_selectedon the source item. - Shift+click — range selection across
unifiedDisplayFilesusinglastSelectedListIndex. - Select all (header checkbox) — selects current page items; shows a "Select all N" banner when total exceeds the current page.
- "Select all N" — calls
confirmSelectAll(), which fetches all asset and folder IDs from the API (fetchAllSelectableIds) and setsinternalSelectAll = true. Maximum manual selection cap: 50 000 items. - Marquee (rubber-band) —
useMarqueeSelection()wired to both grid (mousedownon.grid-lists) and list view (mousedownonDamDetailListView).
Bulk-action bar ​
DamBulkActionsBar (floating) appears when selectedItemsCount > 0. Actions are gated by workspace permissions and disabled (with tooltip) when folders are in the selection for asset-only operations.
| Action | Permission gate | Disabled when folders selected |
|---|---|---|
| Add to Collage | canCreateCollages | Yes |
| Share | canShareDownload | No |
| Download | canShareDownload | No |
| Add Tags | canAddTags | Yes |
| Insert Custom Field | canManageCustomFields | Yes |
| Move | canMoveContent | No |
| Delete | canDeleteContent | No |
Dialogs ​
| Dialog | Trigger |
|---|---|
ShareAssetDialog | Share action on folder or asset, or bulk share |
PermissionDialog | Portals action on folder or asset |
FolderDialog | Move folder / move file / move multiple / copy file / duplicate asset — dialog-type resolved at runtime from dialogStore state |
CreateOrRenameDialog | Rename folder, create subfolder, rename asset |
ConfirmationDialog | Delete folder or asset |
SaveToCollage | Add to Collage (single or bulk asset) |
AddMultipleTags | Add Tags (bulk asset) |
ManageCustomFields | Insert Custom Field (single or bulk asset) |
Key composables consumed ​
| Composable | Purpose |
|---|---|
useFolderDetail | Core data: folder object, paginated displayFiles, dialogs, selection helpers, mutations |
useSearchBar | Query input state and debounced value |
useSearchFilter | Filter chips: categories, applied filters, clear-all |
useSearchApi | Typesense multi-search execution |
useSearchMethods | buildFilterBy for Typesense filter strings |
useMarqueeSelection | Rubber-band drag-select for grid and list |
useViewportFill | Auto-triggers loadMore until the viewport is full on initial load |
useViewMode | Persisted grid/list view preference (key: 'folders') |
useThumbnailSize | Persisted thumbnail size preference (key: 'folders') |
useHelpers / useAuth | Permission checks (canShareDownload, canDeleteContent, etc.) |