Appearance
Search & Discovery ​
Overview ​
The Search system provides full-text search across assets, folders, and collages using Typesense 3.
- GlobalSearch — the header-mounted search bar that renders an overlay dropdown with a preview of results across all three collections, recent searches, recommended tags, and active filter chips. Navigates to the full results page on submit.
- Search Results Page —
pages/[workspace_id]/dam/search.vuerenders a full paginated grid with tab switching, sort controls, view mode (grid/list), and bulk actions. - SearchFilter — a three-level cascading dropdown (category → operator → values) that auto-applies filters on every selection change and syncs active chips to the URL.
- Nitro Proxy —
server/api/typesense/search.post.tskeeps the Typesense API key server-side. All client queries go through this route; the server appendsworkspace_idto every filter, validates the request with Zod, and signs S3 URLs in the response. - Composables —
useSearchApiwraps the proxy endpoints;useSearchFiltersQuerycaches filter-pool data per context (global, folder, collage).
Architecture ​
The client sends every search to Nitro, never to Typesense directly. The Nitro route handles three request shapes: a single-collection search (SearchBody), a multi-search fan-out (MultiSearchBody), and a backward-compat compat-search format (CompatSearchBody). The route validates with Zod, verifies workspace access from the auth header, appends workspace_id: <unique_id> to filter_by, remaps sort fields per collection (display_file_name → folder_name for folders, collection_name for collages), then calls Typesense /multi_search or /collections/:name/documents/search. S3 presigned URLs in hit documents are re-signed server-side before the response reaches the client.
File Structure ​
Vue Component Files ​
app/components/search/GlobalSearch.vue— full-page search overlay mounted in the app header; manages query input, tab selection, recommended tags, recent searches, filter chips, and result preview groupsapp/components/search/SearchFilter.vue— three-level cascading dropdown: Level 1 picks a category, Level 2 picks an operator (skipped for most types, auto-defaulted), Level 3 picks values with multi-select checkboxes or a date picker; auto-applies on each selectionapp/components/search/AddedFilterOption.vue— applied-filter chip rendered as av-btn-togglegroup; supports operator swap, value editing, and removal inlineapp/components/search/SearchTabs.vue— tab strip switching between Assets, Collages, and Folders; optionally includes an "All" tab for the header dropdownapp/components/search/SearchResultGroup.vue— renders a labeled group of search hits (assets or library items) usingDamDetailListVieworDamLibraryListView, with a "View all" linkapp/components/search/SearchAssets.vue— paginated asset results for the full results pageapp/components/search/SearchFolders.vue— folder results list for the full results pageapp/components/search/SearchCollages.vue— collage results for the full results pageapp/components/search/SearchEmptyState.vue— empty state with title and subtitle propsapp/components/search/SearchTableHeader.vue— column header row for the list view on the results pageapp/components/dam/SearchBreadCrumbs.vue— breadcrumb navigation displayed on the search results page
Composable Files ​
app/composables/api/useSearchApi.ts— primary search composable; exposessearchAssets,searchFolders,searchCollages,multiSearch,searchRecommendedTags,retrieveDoc,fetchAllIds,fetchSearchData,getSearchResultsapp/composables/api/useTypesenseApi.ts— lower-level composable wrappingmultiSearch,searchCollection, andsearchIdsproxy endpoints; used by legacy pagesapp/composables/queries/useSearchFiltersQuery.ts— TanStack Query wrapper that fetches and caches filter pools for global, folder, and collage contexts
Page Files ​
app/pages/[workspace_id]/dam/search.vue— full search results page with tabs, filters, sort, view mode, and bulk actions
Route: /:workspace_id/dam/search
Server Route Files ​
server/api/typesense/search.post.ts— unified Nitro proxy handling single-collection, multi-search, and compat-search request shapes; validates with Zod, verifies workspace access, appends workspace filter, signs S3 URLsserver/api/typesense/search-collection.post.ts— single-collection search proxy used byuseTypesenseApi.searchCollectionserver/api/typesense/search-ids.post.ts— bulk ID search proxy used byuseTypesenseApi.searchIdsserver/api/typesense-collection.post.ts— single-document retrieval proxy used byuseSearchApi.retrieveDoc; served at/api/typesense-collection(root, not the typesense subdirectory)server/api/typesense-ids.post.ts— bulk ID fetch proxy for select-all-across-pages flows; used byuseSearchApi.fetchAllIds; served at/api/typesense-ids
Type Files ​
app/types/search.ts—AssetDoc,CollageDoc,FolderDoc,TagDoc,SearchTab,SearchScopeapp/types/filters.ts—FilterCategory,FilterOperator,FilterOption,AppliedFilter,AppliedFilterValue,DateRangeValue,PopularSearchDataapp/types/typesense.ts—TypesenseSearchParams,TypesenseMultiSearchPayload,TypesenseSearchResponse,TypesenseMultiSearchResponse,TypesenseCollection,CompatSearchPayloadapp/constants/search.ts—SEARCH_COLLECTIONS,QUERY_BY,DEFAULT_SORT_STRING
Page Details ​
Search ([workspace_id]/dam/search.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/search |
| Layout | collage-layout |
| Middleware | auth-check, check-workspace, check-if-suspended, can-access-dam-module |
| Primary composable | useSearchResultsPage() |
| Supporting composables | useCollage(), useCollageMenu(), useFolders(), useFolderMenu(), useAssetActions(), useAssetMenu(), useDialogStore(), useRecentSearches(), useAmplitudeSearchTracking(), useThumbnailSize('search_assets') |
Three-tab layout (Assets / Folders / Collages). Each tab supports grid and list view modes, persisted to localStorage. The Assets tab includes a filter panel, display-panel sort/view controls, and a floating DamBulkActionsBar; infinite scroll at 85% with backward-scroll reload for evicted pages.
Bulk actions (Assets tab, permission-gated): Add to Collage, Share, Download, Add Tags, Insert Custom Field
Dialogs per tab
| Tab | Dialogs |
|---|---|
| Assets | ShareAssetDialog, ConfirmationDialog, CreateOrRenameDialog, PermissionDialog, FolderDialog (move/duplicate), SaveToCollage, AddMultipleTags, ManageCustomFields |
| Folders | ShareAssetDialog, ConfirmationDialog, CreateOrRenameDialog, PermissionDialog, FolderDialog (move) |
| Collages | ShareAssetDialog, ConfirmationDialog, PermissionDialog, CreateOrRenameDialog |
GlobalSearch Component ​
File: app/components/search/GlobalSearch.vue
The header search component. Expands into a full-height overlay on focus and collapses on Escape or navigation away from the search page. On the dedicated results page (/dam/search) it acts as the query input driving useSearchResultsPage via the search event bus instead of running its own preview queries.
Features ​
- Ctrl+F / Cmd+F keyboard shortcut opens the search overlay
- 500 ms debounce on preview queries; recent searches persist after 1.2 s
- Recommended tags section under the query box when results include tag hits
- Tab bar (All / Assets / Collages / Folders) with windowed result groups
- Filter chips rendered from
useSearchFiltercomposable; filters carry over to the full results page via the DAM store - Scope awareness — detects current folder or collage context from the URL and scopes preview results accordingly
- Plan gate — when
advancedAllowedis false, showsUpgradePlanSectioninstead of results
Props ​
typescript
interface Props {
advancedAllowed?: boolean // default: true — subscription gate for advanced search
}Events ​
GlobalSearch emits no events; it communicates outward through useSearchEventBus and useDamStore.
Usage ​
vue
<template>
<GlobalSearch :advanced-allowed="hasAdvancedSearch" />
</template>
<script setup lang="ts">
const hasAdvancedSearch = computed(() => subscription.value?.advance_search_functionality)
</script>SearchFilter Component ​
File: app/components/search/SearchFilter.vue
Three-level cascading filter dropdown. Categories are separated into built-in filters and custom-field categories with a divider between them. Categories already applied are disabled. Clicking a category auto-selects the default operator and jumps straight to Level 3. Multi-select filters auto-apply on every checkbox toggle; single-value filters (visibility, orientation, date presets) apply and close the menu on selection.
Features ​
- Category icons via
AsyncIcon—tagsIcon,fileIcon,calendarIcon,lockIcon,cropFreeformIcon,customFieldsIcon - Date range picker using Vuetify
v-date-pickerinmultiple="range"mode; auto-applies once both ends are selected - Contains operator for tags — renders a plain text input instead of a checkbox list
- Client-side search box within multi-select lists
- Selected options float to the top of the list via
useSearchFilterPool - Dropdown height anchored to viewport bottom via CSS variable
--search-filter-offset
Props ​
typescript
interface Props {
categories: FilterCategory[] // built-in filter categories
customFieldCategories?: FilterCategory[] // appended after a divider
applied: AppliedFilter[] // currently active filters — disables matching categories
loading?: boolean // shows loading state while categories are fetching
expandToBottom?: boolean // anchors list height to viewport bottom; default: true
flag?: 'global' | 'global-search' | 'folder-page' | 'collage-page'
}Events ​
typescript
{
'apply-filter': [filter: AppliedFilter] // emitted on every value selection
'remove-filter': [level1: string] // emitted when last value deselected
'clear-all': []
'menu-closed': []
}Usage ​
vue
<template>
<SearchFilter
:categories="filter.categories.value"
:custom-field-categories="filter.customFieldCategories.value"
:applied="filter.applied.value"
:loading="filter.loadingCategories.value"
flag="global-search"
@apply-filter="filter.applyFilter"
@remove-filter="filter.removeFilter"
@clear-all="filter.clearAll"
/>
</template>
<script setup lang="ts">
const filter = useSearchFilter({ context: computed(() => 'global'), syncUrl: true })
onMounted(() => { void filter.loadCategories() })
</script>AddedFilterOption Component ​
File: app/components/search/AddedFilterOption.vue
Renders an applied filter as a button-group chip: [icon Category] [operator dropdown] [value pill / contains input] [X]. Clicking the operator opens a list to swap it. Clicking the value pill opens a ListLevelThree-style picker to edit the selection. Swapping the tags filter operator to/from "contains" preserves prior selections via useDamStore.saveTagsFilterItems.
Props ​
typescript
interface Props {
filter: AppliedFilter // { level1: string, level2: FilterOperator, level3: AppliedFilterValue }
categories: FilterCategory[] // full category list used to resolve display label and options
searchLoading?: boolean // disables the remove button while search is in flight
}Events ​
typescript
{
remove: [] // remove this filter entirely
apply: [filter: AppliedFilter] // re-apply with edited operator or values
'filter-removed': [] // alias for remove, for parent compatibility
}SearchTabs Component ​
File: app/components/search/SearchTabs.vue
A v-tabs strip for switching between result collections. The includeAll prop prepends an "All" tab used only in the GlobalSearch dropdown; the dedicated results page omits it.
Props ​
typescript
interface Props {
modelValue: 'all' | 'assets' | 'collages' | 'folders'
includeAll?: boolean // default: false
}Events ​
typescript
{
'update:model-value': [tab: 'all' | 'assets' | 'collages' | 'folders']
}SearchResultGroup Component ​
File: app/components/search/SearchResultGroup.vue
Renders a labeled group of hits inside the GlobalSearch dropdown using the shared DAM list components. Assets render via DamDetailListView; collages and folders render via DamLibraryListView. Shows a "View all {type} ({total})" link when total > 0.
Props ​
typescript
interface Props {
type: 'assets' | 'collages' | 'folders'
label: string // section heading, e.g. 'ASSETS'
items: FolderAssetItem[]
columns: DamColumn[]
total: number
}Events ​
typescript
{
'item-click': [item: FolderAssetItem]
'view-all': []
}useSearchApi Composable ​
File: app/composables/api/useSearchApi.ts
Primary composable for all Typesense searches. All methods are wrapped with useApiRequestState().track() for shared isLoading and error state.
Methods ​
typescript
// Single-collection searches — auto-sets collection, query_by, and sort_by defaults
searchAssets(p: SearchParams): Promise<TypesenseSearchResponse<AssetDoc>>
searchFolders(p: SearchParams): Promise<TypesenseSearchResponse<FolderDoc>>
searchCollages(p: SearchParams): Promise<TypesenseSearchResponse<CollageDoc>>
// Multi-collection fan-out — one round trip for multiple collections
multiSearch(payload: TypesenseMultiSearchPayload): Promise<TypesenseMultiSearchResponse>
// Recommended tags from digital_assets_tags collection (grouped, deduplicated)
searchRecommendedTags(q: string, workspaceUniqueId: number | string, perPage?: number): Promise<TagDoc[]>
// Fetch a single document by ID from any collection
retrieveDoc<T>(collection: TypesenseCollection, id: string | number, workspace_unique_id: string | number): Promise<{ data: T }>
// Bulk ID fetch for select-all-across-pages
fetchAllIds(p: FetchAllIdsParams): Promise<Partial<Record<TypesenseCollection, Array<string | number>>>>
// Filter pool data — tags, file types, collages, uploaded-by for the filter panel
fetchSearchData(workspaceUniqueId: number | string): Promise<PopularSearchData>
// Backward-compat: used by collage-detail and folder-detail pages
getSearchResults(payload: CompatSearchPayload, token?: string): Promise<TypesenseMultiSearchResponse['results']>Usage ​
vue
<script setup lang="ts">
const { searchAssets, searchFolders, searchCollages, multiSearch, isLoading } = useSearchApi()
// Single collection
const assets = await searchAssets({
q: 'summer',
filter_by: 'file_type:=image',
sort_by: 'created_at:desc',
per_page: 20,
page: 1,
workspace_unique_id: workspaceId,
})
// Multi-search fan-out
const results = await multiSearch({
searches: [
{ collection: 'digital_assets', q: 'summer' },
{ collection: 'dam_collections', q: 'summer' },
{ collection: 'digital_assets_categories', q: 'summer' },
],
common: {
query_by: 'display_file_name,description',
per_page: 5,
page: 1,
},
workspace_unique_id: workspaceId,
})
</script>Typesense Collections ​
| Collection | Contents | Default query_by |
|---|---|---|
digital_assets | Asset documents | display_file_name,description |
digital_assets_categories | Folder documents | folder_name |
dam_collections | Collage documents | collection_name |
digital_assets_tags | Tag documents for recommended-tags lookup | search_name |
Search Workflows ​
Basic Search Workflow ​
1. User focuses the search input
Component: GlobalSearch.vue
- Overlay expands; recent searches appear
↓
2. User types a query
- 500 ms debounce triggers useHeaderSearch
- Calls multiSearch across digital_assets, dam_collections, digital_assets_categories
↓
3. Preview results appear in the dropdown
Components: SearchResultGroup (Assets, Collages, Folders sections)
- Up to 5 hits per collection shown
- Recommended tags shown below the query box
↓
4. User submits (Enter or click search icon)
- Router push to /:workspace_id/dam/search?q=<query>
- Recent search saved to useRecentSearches store
↓
5. Results page loads
Page: pages/[workspace_id]/dam/search.vue
- useSearchResultsPage composable runs its own queries
- Full paginated grid renders
↓
6. User clicks a result
- Asset: router.push to /:workspace_id/dam/files/:id
- Folder: router.push to /:workspace_id/dam/folders/:id
- Collage: router.push to /:workspace_id/dam/collage/:idFilter Application Workflow ​
1. User clicks Filter button
Component: SearchFilter.vue
- Level 1 menu opens: built-in categories then custom-field categories
↓
2. User picks a category (e.g. Tags)
- Default operator auto-selected (include-all-of)
- Level 3 value list opens immediately
↓
3. User selects values (checkboxes for multi-select)
- apply-filter event fires on every toggle
- Filter chip appears via AddedFilterOption
↓
4. Filter carried to search query
Composable: useSearchFilter → damStore.allFilterList
- filter_by string built from all active AppliedFilter objects
- Typesense query re-runs
↓
5. User edits or removes a chip
Component: AddedFilterOption.vue
- Operator pill click → swap operator in-place
- Value pill click → ListLevelThree-style editor
- X click → remove-filter event → filter cleared
↓
6. URL updated with filter state
- Shareable, browser-back-navigableKeyboard Shortcut Workflow ​
1. User presses Ctrl+F (Windows) or Cmd+F (Mac)
Component: GlobalSearch.vue onKeyStroke handler
- Browser find dialog prevented
- Search overlay opens (unless already on search results page)
- Input focused
↓
2. User presses Escape
- Overlay collapses
- Query cleared (unless on search results page)API Integration ​
Nitro Proxy Endpoint ​
Endpoint: POST /api/typesense/search
The server route accepts three request shapes dispatched by body shape detection.
Single-collection request:
json
{
"collection": "digital_assets",
"q": "summer",
"query_by": "display_file_name,description",
"filter_by": "file_type:=image",
"sort_by": "created_at:desc",
"per_page": 20,
"page": 1,
"workspace_unique_id": 123
}Multi-search request (triggered when body contains "searches" key):
json
{
"searches": [
{ "collection": "digital_assets", "q": "summer" },
{ "collection": "dam_collections", "q": "summer" },
{ "collection": "digital_assets_categories", "q": "summer" }
],
"common": {
"query_by": "display_file_name,description",
"per_page": 5,
"page": 1
},
"workspace_unique_id": 123
}Response:
json
{
"results": {
"digital_assets": {
"data": {
"hits": [
{
"document": {
"id": 456,
"display_file_name": "summer-campaign.jpg",
"file_type": "image",
"workspace_id": 123
}
}
],
"found": 45,
"page": 1
},
"totalPages": 3
},
"dam_collections": {
"data": { "hits": [], "found": 8, "page": 1 },
"totalPages": 1
},
"digital_assets_categories": {
"data": { "hits": [], "found": 2, "page": 1 },
"totalPages": 1
}
}
}Filter-Pool Endpoint ​
Endpoint: GET /api/digital-assets/get-search-data
Fetches popular tags, file types, uploaded-by users, and collages for the global filter panel. Called once per session via useSearchApi.fetchSearchData.
json
{
"data": {
"popular_tag_select": [{ "id": 1, "tag_name": "hero" }],
"popular_file_type": [{ "id": "image", "name": "image" }],
"popular_uploaded_by": [{ "user_id": 5, "user_name": "Jane" }],
"collages": [{ "id": 12, "collection_name": "Q4 Campaign" }]
}
}Component Integration ​
The search page ([workspace_id]/dam/search.vue) delegates all search orchestration to the useSearchResultsPage() facade composable. The facade returns scoped result sets for assets, folders, and collages — each with loading state, pagination, selection state, and a sort ref.
vue
<script setup lang="ts">
const {
tab,
setTab,
filter,
bar,
assets,
assetsSort,
folders,
foldersSort,
collages,
collagesSort,
hasSearchIntent,
resultsScrollEl,
assetsItemSelector,
} = useSearchResultsPage()
const { collageActions } = useCollage()
const { folderActions } = useFolders()
const { assetActions } = useAssetActions()
</script>
<template>
<div ref="resultsScrollEl" class="search-page">
<!-- Tab switcher: Assets / Folders / Collages -->
<SearchTabs :model-value="tab" @update:model-value="setTab" />
<!-- Applied filter chips -->
<AddedFilterOption
v-for="f in filter.applied.value"
:key="f.level1"
:filter="f"
:categories="filter.categories.value"
@remove="filter.removeFilter(f.level1)"
@apply="filter.applyFilter"
/>
<!-- Filter sidebar / panel -->
<SearchFilter
:categories="filter.categories.value"
:custom-field-categories="filter.customFieldCategories.value"
:applied="filter.applied.value"
:loading="filter.loadingCategories.value"
flag="global-search"
@apply-filter="filter.applyFilter"
@remove-filter="filter.removeFilter"
@clear-all="filter.clearAll"
/>
<!-- Results: assets tab -->
<template v-if="tab === 'assets'">
<DamBulkActionsBar :selection="assets" :actions="assetActions" />
<DamDisplayPanel :item-selector="assetsItemSelector">
<SearchAssets :results="assets" :sort="assetsSort" />
</DamDisplayPanel>
</template>
<!-- Results: folders tab -->
<template v-else-if="tab === 'folders'">
<SearchFolders :results="folders" :sort="foldersSort" :actions="folderActions" />
</template>
<!-- Results: collages tab -->
<template v-else-if="tab === 'collages'">
<SearchCollages :results="collages" :sort="collagesSort" :actions="collageActions" />
</template>
<!-- Empty state when search has intent but no results on current tab -->
<SearchEmptyState
v-if="hasSearchIntent && tab === 'assets' && !assets.hits.value.length"
/>
</div>
</template>useSearchResultsPage() internally wires useSearchFilter(), useSearchBar(), and three independent Typesense search scopes — the page never calls those lower-level composables directly.