Appearance
Workspace Branding ​
Overview ​
Workspace branding (also called white-labeling) allows customers on eligible plans to replace Collage's default UI chrome — name, logo, favicon, tagline, and Open Graph image — with their own brand identity. The branding system is applied globally: once enabled, every page, portal, share link, and email that renders under the workspace reflects the customer's brand.
- Branding mixin:
mixins/branding.jsis registered globally and overrideshead()on every page that extends it. It readsdam.brandingDetailsfrom the store and injects the appropriate<title>,<meta>, favicon<link>, and web manifest<link>tags viavue-meta. - Store + API resolution:
dam/setBrandingfetches branding details fromPOST get-branding. If a custom favicon is configured, a second request toGET check-brandingretrieves the resolved favicon URL. Results are committed todam.brandingDetails. - Logo and favicon upload: Admins crop and upload their logo via
LogoEditDialog.vue(workspace settings) orPortalLogoEditDialog.vue(per-portal). The logo is stored as a workspace asset and referenced by the branding API response. - CNAME / custom domain: Customers can point a subdomain to the portal.
CNameGuideDialog.vuewalks through DNS record setup for major registrars. The branding API resolves the correct workspace from the request hostname, enabling a fully custom portal URL. - Subscription gate: Branding features are behind the
white_label_enabledflag on the workspace subscription. Whenfalse, the mixin returns Collage defaults. Admins see an upsell prompt atcomponents/dam/StaticPlan/WorkspaceBranding.vue.
Architecture ​
The branding system is intentionally thin at the component level and heavy at the mixin/store level. The mixin runs on every page because it is registered app-wide (or injected per layout). This means no individual page needs to know about branding — it is applied transparently as long as dam.brandingDetails is populated.
Branding is loaded as part of the workspace bootstrap sequence, typically from the damLayout layout or the workspace-level middleware. The setBranding action is called once per workspace session with the workspace_id. The resolved brandingDetails object is then reactive — every page that uses the branding mixin picks up changes without a page reload.
A key subtlety is favicon stability. vue-meta reinjects <link> tags each time a component mounts or unmounts, which would blank the favicon during dialog transitions. mixins/branding.js uses a module-level cache (_lastFaviconKey, _lastFaviconHref) to return the previously-resolved favicon URL when the brand data has not changed, preventing the blank-flash.
For share pages (/shared-assets/...), the branding mixin reads from this.$route.params.token to resolve the workspace indirectly — the share token carries the workspace context needed to load the correct brand.
File Structure ​
JavaScript Files (.js) ​
mixins/branding.js— Global branding mixin- Exports
COLLAGE_DEFAULTSconstant (name, logo, favicon, tagline, og:image URLs) $brandcomputed: gates onwhite_label_enabled; returns brand config or defaults$brandingReadycomputed: true whensetBrandinghas resolvedhead()method: returnsvue-meta-compatible object with title, meta, favicon link, manifest link- Module-level favicon cache prevents blank-flash on dialog mount/unmount cycles
- Exports
store/dam.js— DAM Vuex store (branding slice)brandingDetailsstate: null untilsetBrandingresolves; object with logo, favicon, name, tagline, colors,white_label_enabledsetBranding({ workspace_id })action: callsPOST get-branding, conditionally callsGET check-brandingfor favicon, commits resultSET_BRANDINGmutation: commits the full branding object to state
Vue Component Files (.vue) ​
components/dam/Dialogs/Org-Settings/LogoEditDialog.vue— Workspace logo crop dialog- Two-tab interface: "With Crop" (ImageCropper, 40 H × 300 W recommended) and "Without Crop" (direct upload)
- Favicon tab: single ImageCropper at 124 × 124 px, aspect 1:1
- Emits upload events to the parent settings page for API submission
components/dam/Dialogs/Org-Settings/CNameGuideDialog.vue— CNAME setup guide dialog- Step-by-step DNS configuration panels for GoDaddy, 1&1, and Google Domains
- Expansion panel layout with imported screenshot assets for visual guidance
- Read-only informational dialog; no form submission
components/dam/Dialogs/PortalLogoEditDialog.vue— Portal-specific logo crop dialog- Single-tab ImageCropper at recommended 40 H × 300 W
cropperKeyprop forces re-initialization when a new image is loaded- Used inside portal settings pages (not workspace-level settings)
components/dam/StaticPlan/WorkspaceBranding.vue— Branding upsell component- Rendered when
white_label_enabledis false - Shows an upgrade prompt and lists branding features available on higher plans
- Replaces the live branding settings form on free/starter workspaces
- Rendered when
pages/_workspace_id/workspace-settings/portals/index.vue— Portals settings page- Contains CNAME input, custom domain status indicator, and CNameGuideDialog trigger
- Contains portal logo upload with PortalLogoEditDialog
SVG Icon Components (Branding-related) ​
components/svg/BrandingIcon.vue— Branding section sidebar iconcomponents/svg/CNameIcon.vue— Custom domain / CNAME icon
Branding Mixin ​
File: mixins/branding.js
Features ​
- Transparent global application — pages do not need to opt in beyond extending the mixin
$brandcomputed returns a normalized brand config regardless of whether white-labeling is active- Full Open Graph, Twitter Card, and Apple touch meta tag coverage
- Favicon stability cache prevents blank-favicon bug during dialog transitions
- Share-page support via route param token lookup
Computed Properties ​
javascript
{
$brand: {
// Returns brand config when white_label_enabled, else COLLAGE_DEFAULTS
// Shape:
name: String, // Workspace display name
logo: String, // Logo image URL
favicon: String, // Favicon URL (CDN or uploaded asset)
tagline: String, // Meta description / OG description
og_image: String, // Open Graph image URL
primary_color: String // Portal primary color (hex)
},
$brandingReady: Boolean // True once setBranding has completed
}Head Method ​
javascript
head() {
return {
title: this.$brand.name,
meta: [
{ hid: 'og:site_name', property: 'og:site_name', content: this.$brand.name },
{ hid: 'og:title', property: 'og:title', content: this.$brand.name },
{ hid: 'og:description', property: 'og:description', content: this.$brand.tagline },
{ hid: 'og:image', property: 'og:image', content: this.$brand.og_image },
{ hid: 'twitter:card', name: 'twitter:card', content: 'summary_large_image' },
{ hid: 'twitter:title', name: 'twitter:title', content: this.$brand.name },
{ hid: 'apple-mobile-web-app-title', name: 'apple-mobile-web-app-title',
content: this.$brand.name }
],
link: [
{
rel: 'icon', type: 'image/x-icon',
href: this.$brand.favicon // Stability cache applied internally
},
{
rel: 'manifest',
href: `/manifest.json?workspace=${this.$getWorkspaceId()}`
}
]
}
}Usage ​
vue
<script>
import branding from '~/mixins/branding'
export default {
mixins: [branding],
// head() is now automatically provided by the mixin
}
</script>LogoEditDialog ​
File: components/dam/Dialogs/Org-Settings/LogoEditDialog.vue
Features ​
- Two tabs: "With Crop" (uses
ImageCropper) and "Without Crop" (raw file input) - Favicon mode: single crop at 124 × 124 px, 1:1 aspect ratio
- Emits separate events for logo vs. favicon submissions
Props ​
javascript
{
dialog: {
type: Boolean,
required: true
},
imageData: {
type: String,
default: '' // Base64 data URL of the selected image
},
uploadingLogo: {
type: Boolean,
default: false // Shows loading state on save button
},
internal_workspace_id: {
type: [Number, String],
required: true
},
flag: {
type: String,
default: 'logo' // 'logo' | 'favicon' | 'profile'
},
header: {
type: String,
default: 'Edit Logo'
}
}Events ​
javascript
{
'save': (croppedImageBlob) => {}, // Emits cropped blob for API upload
'close': () => {}, // Dialog dismissed
'without-crop-save': (file) => {} // Raw file for direct upload tab
}CNameGuideDialog ​
File: components/dam/Dialogs/Org-Settings/CNameGuideDialog.vue
Features ​
- Three expansion panels: GoDaddy, 1&1, Google Domains
- Screenshots embedded as imported assets (not external URLs)
- Copy-to-clipboard on CNAME target value
Props ​
javascript
{
dialog: {
type: Boolean,
required: true
}
}Events ​
javascript
{
'close': () => {} // Dialog dismissed
}Workflows ​
Enable Workspace Branding ​
1. Admin navigates to Workspace Settings → Branding
Route: /:workspace_id/workspace-settings/branding
↓
2. Check white_label_enabled flag
If false → WorkspaceBranding.vue upsell is shown
If true → Live branding settings form is shown
↓
3. Admin uploads logo
- Clicks "Change Logo" → LogoEditDialog opens (flag: 'logo')
- Admin crops image (40 H × 300 W recommended)
- Dialog emits 'save' with cropped blob
- Page POSTs blob to workspace logo upload endpoint
- Page calls dam/setBranding to refresh store
↓
4. Admin uploads favicon (optional)
- LogoEditDialog opens (flag: 'favicon', 124 × 124 px)
- Same upload flow as logo
- After upload, GET check-branding confirms favicon is accessible
↓
5. Admin saves brand name and tagline
POST /workspace/update-branding
Body: { name, tagline, og_image_url }
↓
6. On save:
- dam/setBranding dispatched → store updated
- All open pages pick up new $brand computed values
- vue-meta reapplies title, meta, and favicon on next tick
- Analytics event: branding / updatedSet Up Custom Domain (CNAME) ​
1. Admin navigates to Workspace Settings → Portals
Route: /:workspace_id/workspace-settings/portals
↓
2. Admin clicks "Custom Domain" or "Setup CNAME"
→ CNameGuideDialog opens
Shows registrar-specific steps:
Type: CNAME, Host: <subdomain>, Value: portal.collage.inc
↓
3. Admin creates DNS record at their registrar
(Done outside the app — typically 24–48 hour propagation)
↓
4. Admin enters their custom domain in the CNAME input field
POST /workspace/update-cname
Body: { cname: 'assets.brand.com' }
↓
5. Platform verifies the CNAME record resolves correctly
GET /workspace/verify-cname?domain=assets.brand.com
Response: { verified: true | false }
↓
6. On success:
- Portal is accessible at the custom domain
- Share links use the custom domain in their base URL
- Branding API resolves workspace from the custom hostnameLoad Branding on Page Init ​
1. Layout (damLayout) or route middleware calls setBranding
this.$store.dispatch('dam/setBranding', { workspace_id })
↓
2. setBranding action runs
POST /get-branding
Body: { workspace_id }
Response: brandingDetails object
↓
3. If brandingDetails.white_label_enabled && brandingDetails.favicon_path:
GET /check-branding?workspace_id=42
Response: { favicon_url: 'https://cdn.example.com/favicon.ico' }
↓
4. SET_BRANDING mutation commits resolved object to state
↓
5. $brand computed on every page re-evaluates
head() returns updated title, meta, favicon, and manifest
vue-meta patches <head> — favicon cache prevents blank-flashAPI Integration ​
Endpoints ​
| Method | Endpoint | Purpose |
|---|---|---|
POST | get-branding | Load workspace branding details |
GET | check-branding | Verify and resolve custom favicon URL |
POST | workspace/update-branding | Save brand name, tagline, OG image |
POST | workspace/upload-logo | Upload and store workspace logo |
POST | workspace/upload-favicon | Upload and store workspace favicon |
POST | workspace/update-cname | Save custom domain |
GET | workspace/verify-cname | Check DNS propagation status |
Get Branding ​
javascript
// Request
POST /get-branding
{
"workspace_id": 42
}
// Response
{
"data": {
"white_label_enabled": true,
"name": "Acme Assets",
"tagline": "Your brand, your assets.",
"logo_url": "https://cdn.example.com/workspaces/42/logo.png",
"favicon_path": "workspaces/42/favicon.ico",
"og_image_url": "https://cdn.example.com/workspaces/42/og.jpg",
"primary_color": "#E63A2A",
"cname": "assets.acme.com",
"cname_verified": true
}
}Check Branding (Favicon Resolution) ​
javascript
// Request
GET /check-branding?workspace_id=42
// Response
{
"data": {
"favicon_url": "https://cdn.example.com/workspaces/42/favicon.ico"
}
}Update Branding ​
javascript
// Request
POST /workspace/update-branding
{
"workspace_id": 42,
"name": "Acme Assets",
"tagline": "Your brand, your assets.",
"og_image_url": "https://cdn.example.com/workspaces/42/og.jpg"
}
// Response
{
"message": "Branding updated successfully",
"data": {
"name": "Acme Assets",
"tagline": "Your brand, your assets."
}
}Component Integration ​
vue
<template>
<div>
<!-- Branding settings form — shown when white label is enabled -->
<template v-if="$brandingReady && $brand.white_label_enabled">
<div class="brand-logo-section">
<img :src="$brand.logo" alt="Workspace logo" />
<v-btn text @click="openLogoDialog">Change Logo</v-btn>
</div>
<v-text-field
v-model="brandName"
label="Brand Name"
@blur="saveBranding"
/>
<v-text-field
v-model="tagline"
label="Tagline"
@blur="saveBranding"
/>
</template>
<!-- Upsell for workspaces without white-label -->
<template v-else-if="$brandingReady">
<WorkspaceBranding />
</template>
<!-- Logo crop dialog -->
<client-only>
<LogoEditDialog
:dialog="logoDialog"
:imageData="logoImageData"
:uploading-logo="savingLogo"
:internal_workspace_id="workspaceId"
flag="logo"
header="Edit Workspace Logo"
@save="uploadLogo"
@close="logoDialog = false"
/>
<CNameGuideDialog
:dialog="cnameGuideDialog"
@close="cnameGuideDialog = false"
/>
</client-only>
</div>
</template>
<script>
import branding from '~/mixins/branding'
import commonFunctions from '~/mixins/common-functions'
export default {
components: {
LogoEditDialog: () =>
import('~/components/dam/Dialogs/Org-Settings/LogoEditDialog.vue'),
CNameGuideDialog: () =>
import('~/components/dam/Dialogs/Org-Settings/CNameGuideDialog.vue'),
WorkspaceBranding: () =>
import('~/components/dam/StaticPlan/WorkspaceBranding.vue'),
},
mixins: [branding, commonFunctions],
data() {
return {
brandName: '',
tagline: '',
logoDialog: false,
logoImageData: '',
savingLogo: false,
cnameGuideDialog: false,
}
},
async mounted() {
await this.$store.dispatch('dam/setBranding', {
workspace_id: this.$getWorkspaceId(),
})
this.brandName = this.$brand.name
this.tagline = this.$brand.tagline
},
methods: {
openLogoDialog() {
this.logoDialog = true
},
async uploadLogo(croppedBlob) {
this.savingLogo = true
try {
const formData = new FormData()
formData.append('logo', croppedBlob)
formData.append('workspace_id', this.$getWorkspaceId())
await this.$axios.post('/workspace/upload-logo', formData)
await this.$store.dispatch('dam/setBranding', {
workspace_id: this.$getWorkspaceId(),
})
this.$snackbar.success('Logo updated')
} catch (e) {
this.$snackbar.error('Failed to upload logo')
} finally {
this.savingLogo = false
this.logoDialog = false
}
},
async saveBranding() {
try {
await this.$axios.post('/workspace/update-branding', {
workspace_id: this.$getWorkspaceId(),
name: this.brandName,
tagline: this.tagline,
})
await this.$store.dispatch('dam/setBranding', {
workspace_id: this.$getWorkspaceId(),
})
this.$snackbar.success('Branding saved')
} catch (e) {
this.$snackbar.error('Failed to save branding')
}
},
},
}
</script>Related Documentation ​
- File Upload — Logo and favicon images go through a similar file input → crop → upload pipeline
- Collages — Share links surface workspace branding when white-label is enabled
- Portals — Per-portal logo and theme customization uses
PortalLogoEditDialog - DAM Store —
brandingDetails,setBrandingaction,SET_BRANDINGmutation - vue-meta —
head()method contract used by the branding mixin - Mixins - Common Functions —
$getWorkspaceId()used throughout branding setup