From 3a11ed01e9982b39e4687b4752f2e11993bda928 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:56:39 -0500 Subject: [PATCH] Add tenant branding & theming system with admin UI Tenant admins can now customize their workspace appearance: page/nav backgrounds (solid, gradient, or image), column/card colors, borders, text, opacity with glass blur, card corner radius, and accent colors. Light and dark mode are configured separately. Theme CSS is injected server-side via transformPageChunk to prevent FOUC. Includes avatar components, account page updates, and pre-existing type error fixes (Buffer, async onMount, InputJsonValue). Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 57 +++ src/app.css | 14 + src/app.d.ts | 43 ++ src/hooks.server.ts | 13 +- src/lib/components/ActivityFeed.svelte | 23 +- src/lib/components/AssigneePicker.svelte | 9 +- src/lib/components/Avatar.svelte | 46 +++ src/lib/components/AvatarCropEditor.svelte | 204 ++++++++++ src/lib/components/ColorPicker.svelte | 134 +++++++ src/lib/components/CommentList.svelte | 5 +- src/lib/components/ImageUploadField.svelte | 96 +++++ src/lib/components/NotificationBell.svelte | 5 +- src/lib/server/activity.ts | 2 +- src/lib/server/auth.ts | 3 +- src/lib/server/tenant.ts | 7 +- src/lib/server/theme-css.ts | 135 +++++++ src/lib/utils/theme-preview.ts | 119 ++++++ src/routes/+layout.svelte | 15 +- src/routes/account/+page.svelte | 103 +++++ src/routes/admin/+page.svelte | 13 +- src/routes/admin/branding/+page.server.ts | 16 + src/routes/admin/branding/+page.svelte | 369 ++++++++++++++++++ src/routes/api/account/avatar/+server.ts | 57 +++ src/routes/api/admin/branding/+server.ts | 100 +++++ .../api/admin/branding/images/+server.ts | 87 +++++ src/routes/api/avatars/[userId]/+server.ts | 25 ++ .../api/branding/[tenantId]/[slot]/+server.ts | 30 ++ src/routes/boards/+page.svelte | 8 +- src/routes/boards/[boardId]/+page.svelte | 23 +- .../boards/[boardId]/members/+page.svelte | 8 +- 30 files changed, 1709 insertions(+), 60 deletions(-) create mode 100644 src/lib/components/Avatar.svelte create mode 100644 src/lib/components/AvatarCropEditor.svelte create mode 100644 src/lib/components/ColorPicker.svelte create mode 100644 src/lib/components/ImageUploadField.svelte create mode 100644 src/lib/server/theme-css.ts create mode 100644 src/lib/utils/theme-preview.ts create mode 100644 src/routes/admin/branding/+page.server.ts create mode 100644 src/routes/admin/branding/+page.svelte create mode 100644 src/routes/api/account/avatar/+server.ts create mode 100644 src/routes/api/admin/branding/+server.ts create mode 100644 src/routes/api/admin/branding/images/+server.ts create mode 100644 src/routes/api/avatars/[userId]/+server.ts create mode 100644 src/routes/api/branding/[tenantId]/[slot]/+server.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9c79731..8c67e6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model Tenant { tags Tag[] categories Category[] boardTemplates BoardTemplate[] + theme TenantTheme? @@map("tenants") } @@ -329,3 +330,59 @@ model BoardTemplate { @@map("board_templates") } + +// ─── Tenant Branding & Theming ─────────────────────────────── + +model TenantTheme { + id String @id @default(uuid()) @db.Uuid + tenantId String @unique @map("tenant_id") @db.Uuid + + // Light mode + lightPageBg String? @map("light_page_bg") + lightPageBgImage String? @map("light_page_bg_image") + lightNavBg String? @map("light_nav_bg") + lightNavBgImage String? @map("light_nav_bg_image") + lightColumnBg String? @map("light_column_bg") + lightColumnBorder String? @map("light_column_border") + lightColumnText String? @map("light_column_text") + lightColumnOpacity Float? @map("light_column_opacity") + lightCardBg String? @map("light_card_bg") + lightCardBorder String? @map("light_card_border") + lightCardText String? @map("light_card_text") + lightCardOpacity Float? @map("light_card_opacity") + lightCardRadius String? @map("light_card_radius") + lightPrimary String? @map("light_primary") + lightPrimaryHover String? @map("light_primary_hover") + lightDanger String? @map("light_danger") + lightDangerHover String? @map("light_danger_hover") + lightSuccess String? @map("light_success") + lightWarning String? @map("light_warning") + + // Dark mode + darkPageBg String? @map("dark_page_bg") + darkPageBgImage String? @map("dark_page_bg_image") + darkNavBg String? @map("dark_nav_bg") + darkNavBgImage String? @map("dark_nav_bg_image") + darkColumnBg String? @map("dark_column_bg") + darkColumnBorder String? @map("dark_column_border") + darkColumnText String? @map("dark_column_text") + darkColumnOpacity Float? @map("dark_column_opacity") + darkCardBg String? @map("dark_card_bg") + darkCardBorder String? @map("dark_card_border") + darkCardText String? @map("dark_card_text") + darkCardOpacity Float? @map("dark_card_opacity") + darkCardRadius String? @map("dark_card_radius") + darkPrimary String? @map("dark_primary") + darkPrimaryHover String? @map("dark_primary_hover") + darkDanger String? @map("dark_danger") + darkDangerHover String? @map("dark_danger_hover") + darkSuccess String? @map("dark_success") + darkWarning String? @map("dark_warning") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@map("tenant_themes") +} diff --git a/src/app.css b/src/app.css index faed7cf..6bcd6a9 100644 --- a/src/app.css +++ b/src/app.css @@ -4,8 +4,15 @@ @theme { --color-board-bg: #f1f2f4; + --color-page-bg: #f9fafb; + --color-nav-bg: #0079bf; --color-column-bg: #ebecf0; + --color-column-border: transparent; + --color-column-text: inherit; --color-card-bg: #ffffff; + --color-card-border: #e5e7eb; + --color-card-text: inherit; + --color-card-radius: 0.5rem; --color-primary: #0079bf; --color-primary-hover: #026aa7; --color-danger: #eb5a46; @@ -78,8 +85,15 @@ /* Dark mode overrides */ html.dark { --color-board-bg: #111827; + --color-page-bg: #030712; + --color-nav-bg: #3b82f6; --color-column-bg: #1f2937; + --color-column-border: transparent; + --color-column-text: inherit; --color-card-bg: #374151; + --color-card-border: #4b5563; + --color-card-text: inherit; + --color-card-radius: 0.5rem; --color-primary: #3b82f6; --color-primary-hover: #2563eb; --color-danger: #ef4444; diff --git a/src/app.d.ts b/src/app.d.ts index 4381197..7f13b9e 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,47 @@ // See https://svelte.dev/docs/kit/types#app.d.ts import type { PrismaClient } from '@prisma/client'; +export interface TenantThemeData { + lightPageBg?: string | null; + lightPageBgImage?: string | null; + lightNavBg?: string | null; + lightNavBgImage?: string | null; + lightColumnBg?: string | null; + lightColumnBorder?: string | null; + lightColumnText?: string | null; + lightColumnOpacity?: number | null; + lightCardBg?: string | null; + lightCardBorder?: string | null; + lightCardText?: string | null; + lightCardOpacity?: number | null; + lightCardRadius?: string | null; + lightPrimary?: string | null; + lightPrimaryHover?: string | null; + lightDanger?: string | null; + lightDangerHover?: string | null; + lightSuccess?: string | null; + lightWarning?: string | null; + darkPageBg?: string | null; + darkPageBgImage?: string | null; + darkNavBg?: string | null; + darkNavBgImage?: string | null; + darkColumnBg?: string | null; + darkColumnBorder?: string | null; + darkColumnText?: string | null; + darkColumnOpacity?: number | null; + darkCardBg?: string | null; + darkCardBorder?: string | null; + darkCardText?: string | null; + darkCardOpacity?: number | null; + darkCardRadius?: string | null; + darkPrimary?: string | null; + darkPrimaryHover?: string | null; + darkDanger?: string | null; + darkDangerHover?: string | null; + darkSuccess?: string | null; + darkWarning?: string | null; +} + declare global { namespace App { interface Error { @@ -12,6 +53,7 @@ declare global { id: string; name: string; slug: string; + theme?: TenantThemeData | null; } | null; user: { id: string; @@ -19,6 +61,7 @@ declare global { name: string; globalRole: string; tenantRole: string | null; + avatarUrl: string | null; } | null; session: { id: string; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 186114b..2e0c1de 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit'; import { resolveTenant, seedSystemTemplates } from '$lib/server/tenant.js'; import { validateSession, sessionCookieName } from '$lib/server/auth.js'; import { logger } from '$lib/server/logger.js'; +import { generateTenantCSS } from '$lib/server/theme-css.js'; // One-time seed on startup seedSystemTemplates().catch((e) => logger.error({ err: e }, 'Failed to seed system templates')); @@ -30,7 +31,8 @@ export const handle: Handle = async ({ event, resolve }) => { email: session.user.email, name: session.user.name, globalRole: session.user.globalRole, - tenantRole: session.user.tenantRole + tenantRole: session.user.tenantRole, + avatarUrl: session.user.avatarUrl }; // Ensure user belongs to current tenant (unless site admin) @@ -56,7 +58,14 @@ export const handle: Handle = async ({ event, resolve }) => { // 3. Read theme preference event.locals.theme = event.cookies.get('theme') || undefined; - const response = await resolve(event); + // 4. Generate tenant theme CSS for injection + const tenantCSS = generateTenantCSS(tenant?.theme); + + const response = await resolve(event, { + transformPageChunk: tenantCSS + ? ({ html }) => html.replace('', `${tenantCSS}`) + : undefined + }); logger.info({ method: event.request.method, diff --git a/src/lib/components/ActivityFeed.svelte b/src/lib/components/ActivityFeed.svelte index eda39a3..14f33d1 100644 --- a/src/lib/components/ActivityFeed.svelte +++ b/src/lib/components/ActivityFeed.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js'; import { api } from '$lib/utils/api.js'; + import Avatar from '$lib/components/Avatar.svelte'; type Props = { boardId: string; @@ -102,13 +103,14 @@ }); } - onMount(async () => { - const data = await fetchActivities(); - if (data) { - activities = data.activities; - nextCursor = data.nextCursor; - } - loading = false; + onMount(() => { + fetchActivities().then((data) => { + if (data) { + activities = data.activities; + nextCursor = data.nextCursor; + } + loading = false; + }); onBoardEvent('activity:new', onActivityNew); return () => offBoardEvent('activity:new', onActivityNew); @@ -127,11 +129,8 @@ {:else} {#each activities as activity (activity.id)}
-
- {activity.user.name[0].toUpperCase()} +
+

diff --git a/src/lib/components/AssigneePicker.svelte b/src/lib/components/AssigneePicker.svelte index 6fee6ae..3a81ca9 100644 --- a/src/lib/components/AssigneePicker.svelte +++ b/src/lib/components/AssigneePicker.svelte @@ -1,5 +1,6 @@ + +{#if showImg} + {name} (imgError = true)} + /> +{:else} +

+ {initial} +
+{/if} diff --git a/src/lib/components/AvatarCropEditor.svelte b/src/lib/components/AvatarCropEditor.svelte new file mode 100644 index 0000000..932077e --- /dev/null +++ b/src/lib/components/AvatarCropEditor.svelte @@ -0,0 +1,204 @@ + + + +
e.key === 'Escape' && oncancel()}> + +
diff --git a/src/lib/components/ColorPicker.svelte b/src/lib/components/ColorPicker.svelte new file mode 100644 index 0000000..fe8299e --- /dev/null +++ b/src/lib/components/ColorPicker.svelte @@ -0,0 +1,134 @@ + + +
+
+ + {#if value} + + {/if} +
+ + {#if gradient} +
+ + +
+ {/if} + + {#if mode === 'color' || !gradient} +
+ + +
+ {:else} +
+
+ + to + + +
+
+
+ {/if} + + {#if value && mode === 'color'} +
+ {/if} +
diff --git a/src/lib/components/CommentList.svelte b/src/lib/components/CommentList.svelte index 159dfac..a351758 100644 --- a/src/lib/components/CommentList.svelte +++ b/src/lib/components/CommentList.svelte @@ -3,6 +3,7 @@ import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js'; import { formatDistanceToNow } from 'date-fns'; import { api } from '$lib/utils/api.js'; + import Avatar from '$lib/components/Avatar.svelte'; type Comment = { id: string; @@ -109,8 +110,8 @@
{#each localComments as comment}
-
- {comment.user.name[0].toUpperCase()} +
+
diff --git a/src/lib/components/ImageUploadField.svelte b/src/lib/components/ImageUploadField.svelte new file mode 100644 index 0000000..e581de1 --- /dev/null +++ b/src/lib/components/ImageUploadField.svelte @@ -0,0 +1,96 @@ + + +
+ +
+ + + {#if currentUrl} + + {/if} +
+ {#if currentUrl} +
+ Preview +
+ {/if} +
diff --git a/src/lib/components/NotificationBell.svelte b/src/lib/components/NotificationBell.svelte index 3629124..32bad66 100644 --- a/src/lib/components/NotificationBell.svelte +++ b/src/lib/components/NotificationBell.svelte @@ -56,9 +56,8 @@ } } - onMount(async () => { - await fetchNotifications(); - loading = false; + onMount(() => { + fetchNotifications().then(() => { loading = false; }); const socket = getSocket(); socket.on('notification:new', onNewNotification); diff --git a/src/lib/server/activity.ts b/src/lib/server/activity.ts index c407882..d0b2cba 100644 --- a/src/lib/server/activity.ts +++ b/src/lib/server/activity.ts @@ -21,7 +21,7 @@ export async function logActivity(tx: TxClient, opts: LogActivityOptions) { userId: opts.userId, action: opts.action, cardId: opts.cardId ?? undefined, - metadata: opts.metadata ?? undefined + metadata: (opts.metadata as Prisma.InputJsonValue) ?? undefined } }); } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 0740118..567c55e 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -35,7 +35,8 @@ export async function validateSession(sessionId: string) { name: true, globalRole: true, tenantRole: true, - tenantId: true + tenantId: true, + avatarUrl: true } } } diff --git a/src/lib/server/tenant.ts b/src/lib/server/tenant.ts index da04e60..af68b5f 100644 --- a/src/lib/server/tenant.ts +++ b/src/lib/server/tenant.ts @@ -1,4 +1,5 @@ import { prisma } from './db.js'; +import type { TenantThemeData } from '../../app.d.js'; const tenantCache = new Map(); const CACHE_TTL = 60_000; // 1 minute @@ -7,6 +8,7 @@ export interface TenantInfo { id: string; name: string; slug: string; + theme?: TenantThemeData | null; } export async function resolveTenant(hostname: string): Promise { @@ -22,14 +24,15 @@ export async function resolveTenant(hostname: string): Promise = { + // Light mode + lightPageBg: '--color-page-bg', + lightNavBg: '--color-nav-bg', + lightColumnBg: '--color-column-bg', + lightColumnBorder: '--color-column-border', + lightColumnText: '--color-column-text', + lightCardBg: '--color-card-bg', + lightCardBorder: '--color-card-border', + lightCardText: '--color-card-text', + lightCardRadius: '--color-card-radius', + lightPrimary: '--color-primary', + lightPrimaryHover: '--color-primary-hover', + lightDanger: '--color-danger', + lightDangerHover: '--color-danger-hover', + lightSuccess: '--color-success', + lightWarning: '--color-warning', + // Dark mode + darkPageBg: '--color-page-bg', + darkNavBg: '--color-nav-bg', + darkColumnBg: '--color-column-bg', + darkColumnBorder: '--color-column-border', + darkColumnText: '--color-column-text', + darkCardBg: '--color-card-bg', + darkCardBorder: '--color-card-border', + darkCardText: '--color-card-text', + darkCardRadius: '--color-card-radius', + darkPrimary: '--color-primary', + darkPrimaryHover: '--color-primary-hover', + darkDanger: '--color-danger', + darkDangerHover: '--color-danger-hover', + darkSuccess: '--color-success', + darkWarning: '--color-warning' +}; + +function hexToRgba(hex: string, opacity: number): string { + const h = hex.replace('#', ''); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +} + +/** + * Generates a '; + + return css; +} diff --git a/src/lib/utils/theme-preview.ts b/src/lib/utils/theme-preview.ts new file mode 100644 index 0000000..6a379c6 --- /dev/null +++ b/src/lib/utils/theme-preview.ts @@ -0,0 +1,119 @@ +import type { TenantThemeData } from '../../app.d.js'; + +const LIGHT_FIELD_MAP: Record = { + lightPageBg: '--color-page-bg', + lightNavBg: '--color-nav-bg', + lightColumnBg: '--color-column-bg', + lightColumnBorder: '--color-column-border', + lightColumnText: '--color-column-text', + lightCardBg: '--color-card-bg', + lightCardBorder: '--color-card-border', + lightCardText: '--color-card-text', + lightCardRadius: '--color-card-radius', + lightPrimary: '--color-primary', + lightPrimaryHover: '--color-primary-hover', + lightDanger: '--color-danger', + lightDangerHover: '--color-danger-hover', + lightSuccess: '--color-success', + lightWarning: '--color-warning' +}; + +const DARK_FIELD_MAP: Record = { + darkPageBg: '--color-page-bg', + darkNavBg: '--color-nav-bg', + darkColumnBg: '--color-column-bg', + darkColumnBorder: '--color-column-border', + darkColumnText: '--color-column-text', + darkCardBg: '--color-card-bg', + darkCardBorder: '--color-card-border', + darkCardText: '--color-card-text', + darkCardRadius: '--color-card-radius', + darkPrimary: '--color-primary', + darkPrimaryHover: '--color-primary-hover', + darkDanger: '--color-danger', + darkDangerHover: '--color-danger-hover', + darkSuccess: '--color-success', + darkWarning: '--color-warning' +}; + +function hexToRgba(hex: string, opacity: number): string { + const h = hex.replace('#', ''); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +} + +const appliedVars: string[] = []; + +/** + * Applies theme preview by setting CSS vars on document.documentElement.style. + * Only applies fields for the given mode (light or dark). + */ +export function applyThemePreview(draft: TenantThemeData, mode: 'light' | 'dark') { + clearThemePreview(); + + const fieldMap = mode === 'light' ? LIGHT_FIELD_MAP : DARK_FIELD_MAP; + const opacityColumnField = mode === 'light' ? 'lightColumnOpacity' : 'darkColumnOpacity'; + const opacityCardField = mode === 'light' ? 'lightCardOpacity' : 'darkCardOpacity'; + const bgColumnField = mode === 'light' ? 'lightColumnBg' : 'darkColumnBg'; + const bgCardField = mode === 'light' ? 'lightCardBg' : 'darkCardBg'; + const pageBgImageField = mode === 'light' ? 'lightPageBgImage' : 'darkPageBgImage'; + const navBgImageField = mode === 'light' ? 'lightNavBgImage' : 'darkNavBgImage'; + + const el = document.documentElement; + + for (const [field, cssVar] of Object.entries(fieldMap)) { + const value = (draft as any)[field]; + if (value != null && value !== '') { + el.style.setProperty(cssVar, value); + appliedVars.push(cssVar); + } + } + + // Handle opacity + const columnOpacity = (draft as any)[opacityColumnField]; + const columnBg = (draft as any)[bgColumnField]; + if (columnOpacity != null && columnBg && columnBg.startsWith('#')) { + el.style.setProperty('--color-column-bg', hexToRgba(columnBg, columnOpacity)); + if (!appliedVars.includes('--color-column-bg')) appliedVars.push('--color-column-bg'); + if (columnOpacity < 1) { + el.style.setProperty('--column-backdrop-blur', 'blur(8px)'); + appliedVars.push('--column-backdrop-blur'); + } + } + + const cardOpacity = (draft as any)[opacityCardField]; + const cardBg = (draft as any)[bgCardField]; + if (cardOpacity != null && cardBg && cardBg.startsWith('#')) { + el.style.setProperty('--color-card-bg', hexToRgba(cardBg, cardOpacity)); + if (!appliedVars.includes('--color-card-bg')) appliedVars.push('--color-card-bg'); + if (cardOpacity < 1) { + el.style.setProperty('--card-backdrop-blur', 'blur(8px)'); + appliedVars.push('--card-backdrop-blur'); + } + } + + // Handle bg images + const pageBgImage = (draft as any)[pageBgImageField]; + if (pageBgImage) { + el.style.setProperty('--page-bg-image', `url('${pageBgImage}')`); + appliedVars.push('--page-bg-image'); + } + const navBgImage = (draft as any)[navBgImageField]; + if (navBgImage) { + el.style.setProperty('--nav-bg-image', `url('${navBgImage}')`); + appliedVars.push('--nav-bg-image'); + } +} + +/** + * Removes all inline CSS var overrides applied by applyThemePreview. + */ +export function clearThemePreview() { + const el = document.documentElement; + for (const v of appliedVars) { + el.style.removeProperty(v); + } + appliedVars.length = 0; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index abc48f2..4b3cd27 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import SearchBar from '$lib/components/SearchBar.svelte'; import Toast from '$lib/components/Toast.svelte'; import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.svelte'; + import Avatar from '$lib/components/Avatar.svelte'; let { children, data }: { children: Snippet; data: { user: any; tenant: any; theme?: string } } = $props(); @@ -23,8 +24,8 @@ }); -
-