Appearance
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
| Action | Method | Endpoint | Notes |
|---|---|---|---|
| Fetch library | GET | /asset-library/:workspaceId | Returns { folders, assets } |
| Upload asset | POST | /asset-library/upload | FormData: file, brand_id, campaign_id? |
| Update asset | PATCH | /asset-library/:id | Rename (name), move (brand_id, campaign_id) |
| Delete asset | DELETE | /asset-library/:id | |
| Replace file | POST | /asset-library/:id/replace | FormData: file |
| Get single | GET | /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 campaignStore Actions
All mutating operations are async Vuex actions that call the API service, then commit the result:
| Action | Service call | Mutation |
|---|---|---|
fetchAssetLibrary | getAssetLibrary(workspaceId) | setAssetLibraryFolders + setAssetLibraryAssets |
uploadAssetToLibrary | uploadAsset(formData) | addAssetLibraryAsset |
deleteAssetFromLibrary | deleteAsset(id) | removeAssetLibraryAsset |
renameAssetInLibrary | updateAsset(id, { name }) | upsertAssetLibraryAsset |
moveAssetInLibrary | updateAsset(id, { brand_id, campaign_id }) | upsertAssetLibraryAsset |
replaceAssetInLibrary | replaceAsset(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
| Type | Extensions | Thumbnail | Preview | Status |
|---|---|---|---|---|
image | jpg, png, gif, svg, webp | Image itself (via cdnUrl/thumbnailUrl) | Lightbox | Active |
font | woff, woff2, ttf, otf, eot | "Abc" rendered in the font (via FontFace API) | Type sample with editable text | Active |
video | mp4, webm, mov, mkv | Backend-generated thumbnail | Inline <video> player | Upload 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
Navigation
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
| File | Purpose |
|---|---|
src/store/modules/assetLibrary.ts | Vuex store: types, state, mutations, actions, getters |
src/services/assetLibraryService.ts | API service layer (GET, POST, PATCH, DELETE endpoints) |
src/components/AssetLibrary/AssetLibraryContent.vue | Layout orchestrator, selection logic, dialogs |
src/components/AssetLibrary/AssetSidebar.vue | Folder tree navigation |
src/components/AssetLibrary/AssetToolbar.vue | Type filter, sort, search, view toggle |
src/components/AssetLibrary/AssetGrid.vue | Grid view with font loading, rubber band select |
src/components/AssetLibrary/AssetList.vue | List/table view with rubber band select |
src/components/AssetLibrary/AssetPreviewPanel.vue | Detail panel with metadata, actions |
src/components/dialogs/AssetLibraryDialog.vue | Dialog wrapper with fullscreen toggle |
src/services/__tests__/assetLibraryService.test.ts | Unit tests for the service layer |
src/store/modules/__tests__/assetLibrary.test.ts | Unit 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
| Endpoint | Controller | Purpose |
|---|---|---|
POST /v1/assets | CreativeAssetsController | Builder uploads (inline images, fonts, VR files) |
POST /v1/asset-library/upload | AssetLibraryController | Asset 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:
| Column | Required | Purpose |
|---|---|---|
brand_id | Yes (asset library) / No (builder) | Primary scope |
campaign_id | No | Optional campaign scope |
creative_group_id | No | Links to creative group |
creative_id | No | Links to specific creative |
Indexed for fast lookups: brand_id, campaign_id, creative_group_id, creative_id.
Key Backend Files
| File | Purpose |
|---|---|
app/Controllers/Http/AssetLibraryController.ts | CRUD for asset library (index, store, update, destroy, restore) |
app/Controllers/Http/CreativeAssetsController.ts | Builder uploads, video tokens, migration |
app/Models/Asset.ts | Lucid model with soft deletes, brand/campaign/workspace relations |
app/Validators/StoreAssetValidator.ts | Validation for asset library uploads |
app/Services/BunnyStorage.ts | BunnyCDN Storage API wrapper (upload, delete, getPublicUrl) |
app/Services/AssetMetadataExtractor.ts | Image dimensions, font family extraction |
app/Resources/Asset.ts | JSON serializer for API responses |
app/Policies/AssetsPolicy.ts | Authorization (delegates to brand/campaign policies) |
start/routes/asset_library.ts | Route registration |
tests/functional/asset_library/ | Functional tests (create, index, update, destroy, restore) |
tests/functional/creative_assets/upload.spec.ts | Builder 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.