Appearance
Branding & White-Labeling ​
Overview ​
- Three branding composables —
useBrand,useBrandingHead, anduseBrandingTheme— handle brand resolution, document head injection, and Vuetify theme application respectively. useBrandreadsdamStore.brandingDetailsand produces a render-readyBrandcomputed that components and layouts consume. It distinguishes Collage-domain requests from white-label host requests.- SSR plugin (
branding.server.ts) resolves branding before the initial HTML render via three sequential strategies: share page API, workspace ID API, and host-basedcheck-branding. - Client plugin (
branding.client.ts) fires a fallbackcheck-brandingcall for non-workspace, non-share routes where the SSR plugin could not resolve. usePortalBrandingTabcomposable drives the portal branding editor UI, handling logo/favicon upload, color pickers, domain configuration, and portal visibility.PortalBrandingTab.vuerenders the two-column branding settings form inside the portal detail page; it is the primary UI for customizing a portal's visual identity.
Architecture ​
Branding resolves at two moments. On the server, branding.server.ts runs before rendering and tries three strategies in order, calling damStore.setBrandingDetails on the first successful response. This seeds brandingDetails in the Pinia store so useBrand produces the correct Brand object during SSR, preventing a favicon or title flash on initial page load. On the client, useBrandingTheme watches the resolved brand and writes hex color values directly into Vuetify's live light theme, and useBrandingHead drives useHead with title, OG meta, and favicon derived from the brand.
Portal-level branding is separate from the host/workspace branding. It lives in PortalBrandingTab.vue and usePortalBrandingTab.ts and controls the specific visual settings of a brand portal instance (logo, favicon, colors, domain, visibility).
File Structure ​
Branding Composables ​
app/composables/branding/useBrand.ts— ResolvesdamStore.brandingDetailsinto aBrandcomputed; handles three cases: unknown white-label host (blank shell), Collage domain (defaults), and white-label enabled (mapped fields)app/composables/branding/useBrandingHead.ts— DrivesuseHeadwithtagPriority: 'high'; sets title, OG/Twitter meta, and favicon from the resolved brand; suppresses host-brand assets on/shared-assetsroutes unless the share is brandedapp/composables/branding/useBrandingTheme.ts— Watchesbrand.is_brandedanddamStore.brandingDetails?.branding; writes validated hexprimary_color/secondary_colorinto Vuetify's livelighttheme colors
Portal Branding Composable ​
app/composables/core/components/usePortalBrandingTab.ts— Full portal branding composable; manages form state (brand_name,description,brandUrl,domainUrl,primary_color,secondary_color,is_domain,is_pointed,is_public_portal), logo/favicon upload, color updates, portal visibility, and trending/recent upload toggles
Vue Component Files ​
app/components/dam/portals/PortalBrandingTab.vue— Two-column branding editor rendered on the portal detail page; left column: portal metadata and URL/domain config; right column: logo, favicon, and brand color pickersapp/pages/[workspace_id]/dam/portals/[id].vue— Portal detail page; hosts the Customize tab (which rendersPortalBrandingTab), Users tab, and Content Blocks tab
Plugin Files ​
app/plugins/branding.server.ts— SSR-only; resolves branding via three strategies before initial HTML renderapp/plugins/branding.client.ts— Client-only fallback; callsdamStore.fetchCheckBranding()for non-workspace, non-share routes where SSR did not resolve
Store ​
app/stores/dam.ts— HoldsbrandingDetails: BrandingDetails | null; exposessetBrandingDetails,fetchWorkspaceBranding, andfetchCheckBrandingactions
Type Files ​
app/types/store.ts—Brand,BrandingDetails,BrandingColorsinterfacesapp/types/portal.ts—PortalBranding,PortalInstance,BrandForm,BrandingUpdatePayload,SubscriptionFeatures(includescustom_brand_urlanddam_brandingfeature flags)
useBrand ​
File: app/composables/branding/useBrand.ts
Resolves damStore.brandingDetails into a render-ready Brand computed. Returns a brandingReady flag that is true once brandingDetails is non-null.
Features ​
- Host-origin detection via
X-Forwarded-Host/X-Forwarded-Protoheaders on SSR,window.location.originon client - Three resolution modes: unknown white-label host, Collage domain, white-label enabled
- Exports
BLANK_FAVICON(transparent 1x1 SVG) used to prevent the Collage favicon from leaking onto a white-label host before branding resolves - Exports
COLLAGE_DEFAULTSfor fallback field values on blank branded shells
Returned Values ​
javascript
// Brand object shape
{
is_branded: boolean, // true on white-label hosts
brand_name: string, // workspace/portal name
tagline: string | null, // shown in OG description meta
logo: string | null, // full URL to logo asset
favicon: string | null, // full URL to favicon asset
support_email: string | null, // support email for the brand
brand_website_url: string, // request origin on white-label; collage.inc on default
}Usage ​
vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>
<template>
<v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
<span>{{ brand.brand_name || 'Collage' }}</span>
</template>useBrandingHead ​
File: app/composables/branding/useBrandingHead.ts
Drives useHead with tagPriority: 'high' so branded title and favicon override any page-level defaults.
Features ​
- Sets
<title>tobrand.brand_name(suppressed when not branded) - Sets
og:site_name,og:title,og:description,og:image,twitter:card - Sets favicon and shortcut-icon link elements from
brand.favicon || brand.logo - On
/shared-assetsroutes: host-brand assets are suppressed unlessbrand.is_brandedto avoid leaking the host workspace logo onto a partner's shared link - Falls back to
BLANK_FAVICONwhen assets are suppressed
Usage ​
vue
<!-- Call once from the main layout setup — not in individual pages -->
<script setup lang="ts">
useBrandingHead()
</script>useBrandingTheme ​
File: app/composables/branding/useBrandingTheme.ts
Applies white-label primary and secondary colors to Vuetify's light theme at runtime.
Features ​
- Watches
[brand.is_branded, damStore.brandingDetails?.branding]as a tuple; only runs on reference change (store replacesbrandingDetailswholesale, never mutates in place) - Validates hex values with
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/before writing totheme.themes.value.light.colors - Leaves static defaults intact when a color is missing or invalid
Usage ​
vue
<!-- Call once from the main layout setup -->
<script setup lang="ts">
useBrandingTheme()
</script>usePortalBrandingTab ​
File: app/composables/core/components/usePortalBrandingTab.ts
Full page composable for the PortalBrandingTab component. Manages all reactive state and async operations for portal branding configuration.
Features ​
- Form model:
brand_name,description,brandUrl(slug),domainUrl,is_domain(0|1),is_pointed,is_trading,is_recent_upload,is_public_portal,primary_color,secondary_color isDirtycomputed compares text fields only (not colors, not files)colorsDirtyseparate dirty check for color changes- Logo validation: max 10MB, jpg/png only; favicon validation: max 2MB, png/ico/svg
onLogoPick/onFaviconPickread file to data URL then open a crop dialoguploadAsset('logo' | 'favicon', file)builds multipart FormData and callsupdateInstancesubmit()validates, builds FormData (without logo/favicon), callsupdateInstanceupdateBranding(isBrandingDefault)postsupdate-workspace-brandingwith colors +is_branding: 0|1onPortalVisibilityChangepostschange-portal-visibility
Amplitude Events ​
javascript
PORTAL_LOGO_UPDATED
PORTAL_FAVICON_UPDATED
PORTAL_SETTINGS_UPDATED
PORTAL_THEME_UPDATED
PORTAL_BRANDING_UPDATED
PORTAL_VISIBILITY_CHANGEDPortalBrandingTab Component ​
File: app/components/dam/portals/PortalBrandingTab.vue
Two-column layout component rendered inside the portal detail page's Customize tab.
Features ​
- Left column: portal name, meta description, URL mode radio (Collage subdomain vs own domain), slug/domain text field with preview link, Submit/Cancel, display toggles (Trending, Recently Added, Public Portal)
- Right column: logo upload (pick → crop → immediate upload to S3), favicon upload (same flow), brand color pickers via
v-color-picker, Restore To Default checkbox and Update button - Default colors:
#070E22primary,#ffffffsecondary - White-label hosts: when
isBrandedprop is true, forcesis_domain = 1and hides the Collage subdomain radio
Props ​
javascript
{
instance: {
type: Object, // PortalInstance — portal data
required: true
},
contentLoading: {
type: Boolean,
default: false
},
customBrandAllowed: {
type: Boolean, // subscription gate for custom domain
default: false
},
isBranded: {
type: Boolean, // true on white-label hosts
default: false
}
}Events ​
javascript
{
'updated': (instance: PortalInstance) => {} // after successful save
}Usage ​
PortalBrandingTab is rendered inside the Customize tab of the portal detail page. All props come from usePortalDetail() — the portal detail page composable — not from local refs. onInstanceUpdated merges the API echo back via spread (immutable update); if the response shape is unexpected it re-fetches the full instance.
vue
<!-- app/pages/[workspace_id]/dam/portals/[id].vue (Customize tab) -->
<script setup lang="ts">
import type { PortalInstance } from '~/types/portal'
const {
contentLoading,
tab,
instance,
tabLabels,
customBrandAllowed,
whiteLabelEnabled,
fetchInstance,
} = usePortalDetail()
const onInstanceUpdated = (data: unknown): void => {
if (data && typeof data === 'object') {
instance.value = { ...(instance.value || {}), ...(data as PortalInstance) }
} else {
fetchInstance()
}
}
</script>
<template>
<v-window v-model="tab">
<v-window-item v-for="(label, index) in tabLabels" :key="index" :value="index">
<PortalBrandingTab
v-if="label === 'Customize'"
:instance="instance"
:content-loading="contentLoading"
:custom-brand-allowed="customBrandAllowed"
:is-branded="whiteLabelEnabled"
@updated="onInstanceUpdated"
/>
</v-window-item>
</v-window>
</template>Component Integration ​
Branding hooks are wired once at the layout level. Individual pages and components consume useBrand() to read the resolved brand — they never call the Pinia store or the branding API directly.
Layout-level setup ​
useBrandingHead() and useBrandingTheme() are called once from the root app or layout. They have no arguments and return nothing — they install watchers internally.
vue
<!-- app/app.vue or the primary layout -->
<script setup lang="ts">
useBrandingHead() // sets <title>, og:*, and favicon from the resolved brand
useBrandingTheme() // writes primary_color / secondary_color into Vuetify's light theme
</script>Consuming the resolved brand in a component ​
vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>
<template>
<v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
<span>{{ brand.brand_name }}</span>
</template>brandingReady is true once damStore.brandingDetails is non-null. On SSR it is true immediately if the server plugin resolved branding before rendering. On the client it becomes true after the store hydrates from the SSR payload.
Portal branding editor ​
See PortalBrandingTab — Usage above for the real portal-page integration pattern. The key point: props come from usePortalDetail(), not from local refs constructed in the page.
Workflows ​
SSR Branding Resolution Flow ​
1. Server receives request for any route
Plugin: branding.server.ts (SSR only)
↓
2. Step 1 — share page check
If route matches /shared-assets/:type
→ POST show-share-assets with type, brand_name, status
→ If response has favicon/logo/brand_name
→ damStore.setBrandingDetails(data) → return early
↓
3. Step 2 — workspace route check
If route has workspace_id param
→ POST get-branding with workspace_id
→ If response data present
→ damStore.setBrandingDetails(data) → return early
↓
4. Step 3 — host-based check
Derive origin from X-Forwarded-Proto/Host headers
→ GET check-branding with Origin + X-Forwarded-Host headers
→ If response data present
→ damStore.setBrandingDetails(data)
↓
5. Client hydrates
Nuxt SSR payload includes brandingDetails from store
→ useBrand() computed resolves immediately on client
→ useBrandingHead() sets title/favicon from server-resolved brand
→ useBrandingTheme() applies colors to Vuetify themeClient Fallback Branding Flow ​
1. Page loads on client (non-workspace, non-share route)
Plugin: branding.client.ts
→ Skips if route has workspace_id param
→ Skips if route path matches /shared-assets
→ Skips if damStore.brandingDetails?.white_label_enabled is already true
↓
2. Fire-and-forget fetch
→ damStore.fetchCheckBranding()
→ GET check-branding (client sends real Host header via proxy)
→ On response: damStore.brandingDetails updated reactively
↓
3. useBrand() recomputes
→ Cascades to useBrandingHead() and useBrandingTheme()
→ Title, favicon, and colors update without page reloadPortal Branding Update Flow ​
1. Admin navigates to portal detail page
Route: /:workspace_id/dam/portals/:id
Middleware: can-access-dam-settings.ts
→ Checks DAM access and portal settings capability
↓
2. Customize tab renders PortalBrandingTab
→ usePortalBrandingTab() hydrates form via buildForm(instance)
↓
3. Admin updates portal name, URL, meta description
→ isDirty computed detects changes
→ submit() validates, builds FormData, calls updateInstance (PUT)
→ On success: emits 'updated' with API response
↓
4. Admin uploads logo
→ onLogoPick reads file → validates (jpg/png, max 10MB)
→ Opens crop dialog for adjustment
→ uploadAsset('logo', croppedFile) builds FormData with logo + instance_id
→ PUT update-instance (multipart)
→ Preview updated from API echo
→ Amplitude: PORTAL_LOGO_UPDATED
↓
5. Admin updates brand colors
→ Primary/secondary color pickers via v-color-picker
→ updateBranding(false) posts update-workspace-branding
→ Amplitude: PORTAL_THEME_UPDATED
↓
6. Admin configures custom domain
→ Selects "Own Domain" radio (plan-gated by customBrandAllowed)
→ Enters domain URL (e.g., assets.acme.com)
→ submit() includes is_domain: 1, domainUrl in payload
→ DNS verification flow triggered separately via Account SettingsAPI Integration ​
Branding Endpoints ​
| Method | Endpoint | Description |
|---|---|---|
| GET | check-branding | Host-based brand lookup (Origin header required) |
| POST | get-branding | Workspace brand lookup by workspace_id |
| POST | show-share-assets | Share page brand lookup |
| POST | update-workspace-branding | Update portal primary/secondary colors |
| PUT | update-instance | Update portal metadata and domain settings |
json
// POST get-branding
{
"workspace_id": 42
}
// Response
{
"data": {
"id": 7,
"brand_name": "Acme Brand Assets",
"logo": "https://cdn.collage.inc/logos/acme.png",
"favicon": "https://cdn.collage.inc/favicons/acme.ico",
"tagline": "Official brand resources for Acme partners.",
"support_email": "[email protected]",
"white_label_enabled": true,
"branding": {
"primary_color": "#0A1F44",
"secondary_color": "#F4A300"
}
}
}json
// POST update-workspace-branding
{
"workspace_id": 42,
"instance_id": 7,
"primary_color": "#0A1F44",
"secondary_color": "#F4A300",
"is_branding": 1
}Related Documentation ​
- Portals — Brand portal creation and management
- Workspace — Account settings including logo and favicon upload
- Permissions — Brand portal settings access control
- Subscription — Plan gates for
custom_brand_urlanddam_branding - Real-time — Pusher channels for live branding updates