Skip to content

Asset Library System

The asset library is a dialog-based file manager for brand and campaign assets (images, fonts, videos). It's available from Brands, Campaigns, Creative Groups, and Creatives pages.

Architecture

AssetLibraryDialog          (modal shell, fullscreen toggle)
  └── AssetLibraryContent   (main layout orchestrator)
        ├── AssetSidebar     (folder tree: brands → campaigns)
        ├── AssetToolbar     (type filter, sort, search, view toggle)
        ├── AssetGrid        (thumbnail grid view)
        ├── AssetList        (table list view)
        └── AssetPreviewPanel (right sidebar detail view)

Store: src/store/modules/assetLibrary.ts — Vuex module with all state, mutations, actions, and getters.

Service: src/services/assetLibraryService.ts — Axios-based API layer.

API Endpoints

ActionMethodEndpointNotes
Fetch libraryGET/asset-library/:workspaceIdReturns { folders, assets }
Upload assetPOST/asset-library/uploadFormData: file, brand_id, campaign_id?
Update assetPATCH/asset-library/:idRename (name), move (brand_id, campaign_id)
Delete assetDELETE/asset-library/:id
Replace filePOST/asset-library/:id/replaceFormData: file
Get singleGET/asset-library/:id/show

Key Concepts

Scoping

Every asset belongs to a brand and optionally to a campaign within that brand. Determined by brandId (always present) and campaignId (null = brand-level asset). The sidebar shows a folder tree matching this hierarchy.

Brand assets     → campaignId is null, shared across all campaigns in that brand
Campaign assets  → campaignId is set, specific to one campaign

Store Actions

All mutating operations are async Vuex actions that call the API service, then commit the result:

ActionService callMutation
fetchAssetLibrarygetAssetLibrary(workspaceId)setAssetLibraryFolders + setAssetLibraryAssets
uploadAssetToLibraryuploadAsset(formData)addAssetLibraryAsset
deleteAssetFromLibrarydeleteAsset(id)removeAssetLibraryAsset
renameAssetInLibraryupdateAsset(id, { name })upsertAssetLibraryAsset
moveAssetInLibraryupdateAsset(id, { brand_id, campaign_id })upsertAssetLibraryAsset
replaceAssetInLibraryreplaceAsset(id, formData)upsertAssetLibraryAsset

The upsertAssetLibraryAsset mutation replaces the asset in-place by ID (or prepends if new), and updates selectedAsset if it matches.

Asset Types

TypeExtensionsThumbnailPreviewStatus
imagejpg, png, gif, svg, webpImage itself (via cdnUrl/thumbnailUrl)LightboxActive
fontwoff, woff2, ttf, otf, eot"Abc" rendered in the font (via FontFace API)Type sample with editable textActive
videomp4, webm, mov, mkvBackend-generated thumbnailInline <video> playerUpload disabled (coming soon)

View Modes

  • Grid — Thumbnail cards in a responsive CSS grid (minmax(120px, 1fr)). Best for visual browsing.
  • List — Table rows with columns: icon, name, type, size, scope. Best for managing many assets.

View mode preference is persisted to localStorage.

Features

Upload

Files can be uploaded via:

  • Upload button in the sidebar (respects active type filter)
  • Drag and drop onto the asset area (visual drop indicator with inset border)

Upload sends a FormData with file, brand_id, and optional campaign_id to POST /asset-library/upload. Metadata extraction (dimensions, font family, thumbnails) is handled server-side. Upload progress is tracked via uploadProgress state and Axios onUploadProgress.

Video uploads are temporarily blocked — the frontend shows "Video support coming soon" and the type pill is disabled.

Multi-Select

  • Click — single select (opens preview panel)
  • Ctrl/Cmd + Click — toggle individual asset in selection
  • Shift + Click — range select between last-clicked and current
  • Rubber band drag — click and drag on empty space to lasso-select multiple assets (both grid and list views)
  • Escape — clears multi-selection

When 2+ assets are selected, a bulk action bar appears with Move and Delete actions.

Replace In-Place

Assets can be replaced without changing their ID (preserving all references in creatives):

  • Available via the context menu ("Replace file…") and the preview panel
  • Opens a file picker filtered to the asset's type
  • Sends the new file to POST /asset-library/:id/replace
  • Backend handles metadata extraction and old file cleanup
  • Store upserts the returned asset via upsertAssetLibraryAsset

Preview

  • Preview panel (right sidebar) — shows metadata, dimensions, scope, upload date, and action buttons
  • Lightbox — full-size image/video overlay (click the eye icon on image/video thumbnails)
  • Font preview — renders the font via FontFace API with an editable text input

The folder tree is fetched from GET /asset-library/:workspaceId each time the dialog opens. The response includes both the folder hierarchy and all assets for the workspace. The assetLibraryFolders getter reads directly from store state (no longer derived from rootState brand/campaign data).

Resizable Sidebars

Both sidebars (left folder tree, right preview panel) are resizable via drag handles. Widths are percentage-based so they scale correctly with fullscreen toggle. Constraints: left 14–30%, right 12–30%.

File Map

FilePurpose
src/store/modules/assetLibrary.tsVuex store: types, state, mutations, actions, getters
src/services/assetLibraryService.tsAPI service layer (GET, POST, PATCH, DELETE endpoints)
src/components/AssetLibrary/AssetLibraryContent.vueLayout orchestrator, selection logic, dialogs
src/components/AssetLibrary/AssetSidebar.vueFolder tree navigation
src/components/AssetLibrary/AssetToolbar.vueType filter, sort, search, view toggle
src/components/AssetLibrary/AssetGrid.vueGrid view with font loading, rubber band select
src/components/AssetLibrary/AssetList.vueList/table view with rubber band select
src/components/AssetLibrary/AssetPreviewPanel.vueDetail panel with metadata, actions
src/components/dialogs/AssetLibraryDialog.vueDialog wrapper with fullscreen toggle
src/services/__tests__/assetLibraryService.test.tsUnit tests for the service layer
src/store/modules/__tests__/assetLibrary.test.tsUnit tests for store actions

Types

ts
type AssetType = 'image' | 'font' | 'video'

type Asset = {
  id: string
  name: string
  type: AssetType
  brandId: string
  campaignId: string | null    // null = brand-level asset
  workspaceId: string
  cdnUrl: string
  thumbnailUrl: string | null
  fileSize: number | null
  mimeType: string | null
  metadata: {
    width?: number
    height?: number
    duration?: number
    fontFamily?: string
  } | null
  createdAt: string
  updatedAt: string
}

Known Issues to Verify

  • Workspace scoping — During early development the asset list occasionally showed assets from all workspaces rather than just the active one. This was never root-caused. When testing the real API integration, verify that assets are correctly scoped to the current workspace and don't leak across workspace boundaries.

Backend Architecture

See also: backend-testing-patterns.md for AdonisJS testing conventions.

Storage

All assets are stored on BunnyCDN Storage (object store). Files are uploaded via BunnyStorage.upload(path, buffer) and served via BunnyCDN's edge CDN. The public URL is built from the BUNNY_STORAGE_CDN_URL env variable.

Storage paths follow a hierarchical structure:

{workspaceId}/{brandId}/{campaignId}/{creativeGroupId}/{creativeId}/{cuid}.{ext}

Only workspaceId and brandId are required. Deeper segments are included when available. Uploads without a brand context use general/{cuid}.{ext}.

WebP Conversion

Both upload endpoints (CreativeAssetsController.store and AssetLibraryController.store) auto-convert jpg, jpeg, and png files to WebP using sharp. GIF and WebP files pass through unchanged to avoid breaking animations or double-converting.

Two Upload Endpoints

EndpointControllerPurpose
POST /v1/assetsCreativeAssetsControllerBuilder uploads (inline images, fonts, VR files)
POST /v1/asset-library/uploadAssetLibraryControllerAsset library uploads

Both upload to BunnyCDN and create Asset DB rows (when brand context is available). The builder endpoint accepts uploads without a brand (uses general/ path and skips the DB row), while the asset library endpoint always requires brand_id.

Asset Scoping

Assets have four scope levels, each nullable except brand:

ColumnRequiredPurpose
brand_idYes (asset library) / No (builder)Primary scope
campaign_idNoOptional campaign scope
creative_group_idNoLinks to creative group
creative_idNoLinks to specific creative

Indexed for fast lookups: brand_id, campaign_id, creative_group_id, creative_id.

Key Backend Files

FilePurpose
app/Controllers/Http/AssetLibraryController.tsCRUD for asset library (index, store, update, destroy, restore)
app/Controllers/Http/CreativeAssetsController.tsBuilder uploads, video tokens, migration
app/Models/Asset.tsLucid model with soft deletes, brand/campaign/workspace relations
app/Validators/StoreAssetValidator.tsValidation for asset library uploads
app/Services/BunnyStorage.tsBunnyCDN Storage API wrapper (upload, delete, getPublicUrl)
app/Services/AssetMetadataExtractor.tsImage dimensions, font family extraction
app/Resources/Asset.tsJSON serializer for API responses
app/Policies/AssetsPolicy.tsAuthorization (delegates to brand/campaign policies)
start/routes/asset_library.tsRoute registration
tests/functional/asset_library/Functional tests (create, index, update, destroy, restore)
tests/functional/creative_assets/upload.spec.tsBuilder upload tests (WebP, DB rows, paths)

Current Status

Backend API integration in progress (PR #559 on Application-Backend), replacing mock data from the original frontend-only implementation (PR #1810). Builder uploads now go through BunnyCDN (migrated from local Drive storage). Video upload is the main remaining gap.

Internal documentation