Skip to content

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.

  1. Branding mixin: mixins/branding.js is registered globally and overrides head() on every page that extends it. It reads dam.brandingDetails from the store and injects the appropriate <title>, <meta>, favicon <link>, and web manifest <link> tags via vue-meta.
  2. Store + API resolution: dam/setBranding fetches branding details from POST get-branding. If a custom favicon is configured, a second request to GET check-branding retrieves the resolved favicon URL. Results are committed to dam.brandingDetails.
  3. Logo and favicon upload: Admins crop and upload their logo via LogoEditDialog.vue (workspace settings) or PortalLogoEditDialog.vue (per-portal). The logo is stored as a workspace asset and referenced by the branding API response.
  4. CNAME / custom domain: Customers can point a subdomain to the portal. CNameGuideDialog.vue walks through DNS record setup for major registrars. The branding API resolves the correct workspace from the request hostname, enabling a fully custom portal URL.
  5. Subscription gate: Branding features are behind the white_label_enabled flag on the workspace subscription. When false, the mixin returns Collage defaults. Admins see an upsell prompt at components/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_DEFAULTS constant (name, logo, favicon, tagline, og:image URLs)
    • $brand computed: gates on white_label_enabled; returns brand config or defaults
    • $brandingReady computed: true when setBranding has resolved
    • head() method: returns vue-meta-compatible object with title, meta, favicon link, manifest link
    • Module-level favicon cache prevents blank-flash on dialog mount/unmount cycles
  • store/dam.js — DAM Vuex store (branding slice)

    • brandingDetails state: null until setBranding resolves; object with logo, favicon, name, tagline, colors, white_label_enabled
    • setBranding({ workspace_id }) action: calls POST get-branding, conditionally calls GET check-branding for favicon, commits result
    • SET_BRANDING mutation: 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
    • cropperKey prop 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_enabled is false
    • Shows an upgrade prompt and lists branding features available on higher plans
    • Replaces the live branding settings form on free/starter workspaces
  • 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
  • components/svg/BrandingIcon.vue — Branding section sidebar icon
  • components/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
  • $brand computed 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 / updated

Set 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 hostname

Load 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-flash

API Integration ​

Endpoints ​

MethodEndpointPurpose
POSTget-brandingLoad workspace branding details
GETcheck-brandingVerify and resolve custom favicon URL
POSTworkspace/update-brandingSave brand name, tagline, OG image
POSTworkspace/upload-logoUpload and store workspace logo
POSTworkspace/upload-faviconUpload and store workspace favicon
POSTworkspace/update-cnameSave custom domain
GETworkspace/verify-cnameCheck 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>
  • 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, setBranding action, SET_BRANDING mutation
  • vue-meta — head() method contract used by the branding mixin
  • Mixins - Common Functions — $getWorkspaceId() used throughout branding setup