Private
Public Access
1
0

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 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-26 21:56:39 -05:00
parent 029b467eae
commit 3a11ed01e9
30 changed files with 1709 additions and 60 deletions
+57
View File
@@ -22,6 +22,7 @@ model Tenant {
tags Tag[] tags Tag[]
categories Category[] categories Category[]
boardTemplates BoardTemplate[] boardTemplates BoardTemplate[]
theme TenantTheme?
@@map("tenants") @@map("tenants")
} }
@@ -329,3 +330,59 @@ model BoardTemplate {
@@map("board_templates") @@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")
}
+14
View File
@@ -4,8 +4,15 @@
@theme { @theme {
--color-board-bg: #f1f2f4; --color-board-bg: #f1f2f4;
--color-page-bg: #f9fafb;
--color-nav-bg: #0079bf;
--color-column-bg: #ebecf0; --color-column-bg: #ebecf0;
--color-column-border: transparent;
--color-column-text: inherit;
--color-card-bg: #ffffff; --color-card-bg: #ffffff;
--color-card-border: #e5e7eb;
--color-card-text: inherit;
--color-card-radius: 0.5rem;
--color-primary: #0079bf; --color-primary: #0079bf;
--color-primary-hover: #026aa7; --color-primary-hover: #026aa7;
--color-danger: #eb5a46; --color-danger: #eb5a46;
@@ -78,8 +85,15 @@
/* Dark mode overrides */ /* Dark mode overrides */
html.dark { html.dark {
--color-board-bg: #111827; --color-board-bg: #111827;
--color-page-bg: #030712;
--color-nav-bg: #3b82f6;
--color-column-bg: #1f2937; --color-column-bg: #1f2937;
--color-column-border: transparent;
--color-column-text: inherit;
--color-card-bg: #374151; --color-card-bg: #374151;
--color-card-border: #4b5563;
--color-card-text: inherit;
--color-card-radius: 0.5rem;
--color-primary: #3b82f6; --color-primary: #3b82f6;
--color-primary-hover: #2563eb; --color-primary-hover: #2563eb;
--color-danger: #ef4444; --color-danger: #ef4444;
+43
View File
@@ -1,6 +1,47 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
import type { PrismaClient } from '@prisma/client'; 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 { declare global {
namespace App { namespace App {
interface Error { interface Error {
@@ -12,6 +53,7 @@ declare global {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
theme?: TenantThemeData | null;
} | null; } | null;
user: { user: {
id: string; id: string;
@@ -19,6 +61,7 @@ declare global {
name: string; name: string;
globalRole: string; globalRole: string;
tenantRole: string | null; tenantRole: string | null;
avatarUrl: string | null;
} | null; } | null;
session: { session: {
id: string; id: string;
+11 -2
View File
@@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { resolveTenant, seedSystemTemplates } from '$lib/server/tenant.js'; import { resolveTenant, seedSystemTemplates } from '$lib/server/tenant.js';
import { validateSession, sessionCookieName } from '$lib/server/auth.js'; import { validateSession, sessionCookieName } from '$lib/server/auth.js';
import { logger } from '$lib/server/logger.js'; import { logger } from '$lib/server/logger.js';
import { generateTenantCSS } from '$lib/server/theme-css.js';
// One-time seed on startup // One-time seed on startup
seedSystemTemplates().catch((e) => logger.error({ err: e }, 'Failed to seed system templates')); 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, email: session.user.email,
name: session.user.name, name: session.user.name,
globalRole: session.user.globalRole, 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) // Ensure user belongs to current tenant (unless site admin)
@@ -56,7 +58,14 @@ export const handle: Handle = async ({ event, resolve }) => {
// 3. Read theme preference // 3. Read theme preference
event.locals.theme = event.cookies.get('theme') || undefined; 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('<head>', `<head>${tenantCSS}`)
: undefined
});
logger.info({ logger.info({
method: event.request.method, method: event.request.method,
+6 -7
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js'; import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js';
import { api } from '$lib/utils/api.js'; import { api } from '$lib/utils/api.js';
import Avatar from '$lib/components/Avatar.svelte';
type Props = { type Props = {
boardId: string; boardId: string;
@@ -102,13 +103,14 @@
}); });
} }
onMount(async () => { onMount(() => {
const data = await fetchActivities(); fetchActivities().then((data) => {
if (data) { if (data) {
activities = data.activities; activities = data.activities;
nextCursor = data.nextCursor; nextCursor = data.nextCursor;
} }
loading = false; loading = false;
});
onBoardEvent('activity:new', onActivityNew); onBoardEvent('activity:new', onActivityNew);
return () => offBoardEvent('activity:new', onActivityNew); return () => offBoardEvent('activity:new', onActivityNew);
@@ -127,11 +129,8 @@
{:else} {:else}
{#each activities as activity (activity.id)} {#each activities as activity (activity.id)}
<div class="flex gap-2 text-sm"> <div class="flex gap-2 text-sm">
<div <div class="flex-shrink-0 mt-0.5">
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center flex-shrink-0 mt-0.5" <Avatar name={activity.user.name} avatarUrl={activity.user.avatarUrl} size="sm" title={activity.user.name} />
title={activity.user.name}
>
{activity.user.name[0].toUpperCase()}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-gray-700 dark:text-gray-300 leading-snug"> <p class="text-gray-700 dark:text-gray-300 leading-snug">
+3 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/utils/api.js'; import { api } from '$lib/utils/api.js';
import Avatar from '$lib/components/Avatar.svelte';
type Assignee = { id: string; userId: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } }; type Assignee = { id: string; userId: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } };
type Member = { id: string; userId: string; role: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } }; type Member = { id: string; userId: string; role: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } };
@@ -60,9 +61,7 @@
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each currentAssignees as assignee} {#each currentAssignees as assignee}
<div class="flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-1 pr-2 py-0.5"> <div class="flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-1 pr-2 py-0.5">
<div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center"> <Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" />
{assignee.user.name[0].toUpperCase()}
</div>
<span class="text-xs text-gray-700 dark:text-gray-300">{assignee.user.name}</span> <span class="text-xs text-gray-700 dark:text-gray-300">{assignee.user.name}</span>
{#if canEdit} {#if canEdit}
<button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5"> <button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5">
@@ -91,9 +90,7 @@
onclick={() => toggleAssignee(member)} onclick={() => toggleAssignee(member)}
class="w-full flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 {isAssigned(member.userId) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}" class="w-full flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 {isAssigned(member.userId) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}"
> >
<div class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center"> <Avatar name={member.user.name} avatarUrl={member.user.avatarUrl} size="sm" />
{member.user.name[0].toUpperCase()}
</div>
<span class="flex-1 text-left">{member.user.name}</span> <span class="flex-1 text-left">{member.user.name}</span>
{#if isAssigned(member.userId)} {#if isAssigned(member.userId)}
<svg class="w-4 h-4 text-blue-500" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-4 h-4 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
type Size = 'xs' | 'sm' | 'md' | 'lg';
type Props = {
name: string;
avatarUrl?: string | null;
size?: Size;
[key: string]: any;
};
let { name, avatarUrl, size = 'sm', ...restProps }: Props = $props();
const sizeClasses: Record<Size, string> = {
xs: 'w-5 h-5 text-[10px]',
sm: 'w-6 h-6 text-[10px]',
md: 'w-7 h-7 text-xs',
lg: 'w-9 h-9 text-sm'
};
let imgError = $state(false);
// Reset error when avatarUrl changes
$effect(() => {
if (avatarUrl) imgError = false;
});
const initial = $derived(name?.[0]?.toUpperCase() ?? '?');
const showImg = $derived(!!avatarUrl && !imgError);
const classes = $derived(sizeClasses[size]);
</script>
{#if showImg}
<img
src={avatarUrl}
alt={name}
class="rounded-full object-cover {classes} {restProps.class ?? ''}"
onerror={() => (imgError = true)}
/>
{:else}
<div
class="rounded-full bg-[var(--color-primary)] text-white flex items-center justify-center font-medium flex-shrink-0 {classes} {restProps.class ?? ''}"
title={restProps.title ?? name}
>
{initial}
</div>
{/if}
+204
View File
@@ -0,0 +1,204 @@
<script lang="ts">
import { trapFocus } from '$lib/utils/focus-trap.js';
type Props = {
file: File;
onconfirm: (blob: Blob) => void;
oncancel: () => void;
};
let { file, onconfirm, oncancel }: Props = $props();
const CANVAS_SIZE = 280;
const VIEWPORT_RADIUS = 120;
const OUTPUT_SIZE = 256;
let canvas: HTMLCanvasElement;
let img: HTMLImageElement | null = $state(null);
let scale = $state(1);
let minScale = $state(1);
let offsetX = $state(0);
let offsetY = $state(0);
let dragging = $state(false);
let dragStartX = 0;
let dragStartY = 0;
let startOffsetX = 0;
let startOffsetY = 0;
// Load image from file
$effect(() => {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
img = image;
// Calculate minimum scale so image covers viewport circle
const viewportDiameter = VIEWPORT_RADIUS * 2;
minScale = Math.max(viewportDiameter / image.width, viewportDiameter / image.height);
scale = minScale;
offsetX = 0;
offsetY = 0;
};
image.src = url;
return () => URL.revokeObjectURL(url);
});
// Redraw canvas when state changes
$effect(() => {
if (!canvas || !img) return;
const ctx = canvas.getContext('2d')!;
const cx = CANVAS_SIZE / 2;
const cy = CANVAS_SIZE / 2;
// Clear
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
// Draw image centered + offset
const w = img.width * scale;
const h = img.height * scale;
const x = cx - w / 2 + offsetX;
const y = cy - h / 2 + offsetY;
ctx.drawImage(img, x, y, w, h);
// Dark overlay outside circle
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
ctx.arc(cx, cy, VIEWPORT_RADIUS, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fill();
ctx.restore();
// Circle border
ctx.beginPath();
ctx.arc(cx, cy, VIEWPORT_RADIUS, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
ctx.lineWidth = 2;
ctx.stroke();
});
function constrainOffset() {
if (!img) return;
const w = img.width * scale;
const h = img.height * scale;
const maxX = Math.max(0, (w - VIEWPORT_RADIUS * 2) / 2);
const maxY = Math.max(0, (h - VIEWPORT_RADIUS * 2) / 2);
offsetX = Math.max(-maxX, Math.min(maxX, offsetX));
offsetY = Math.max(-maxY, Math.min(maxY, offsetY));
}
function onPointerDown(e: PointerEvent) {
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
startOffsetX = offsetX;
startOffsetY = offsetY;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
offsetX = startOffsetX + (e.clientX - dragStartX);
offsetY = startOffsetY + (e.clientY - dragStartY);
constrainOffset();
}
function onPointerUp() {
dragging = false;
}
function onScaleChange(e: Event) {
scale = parseFloat((e.target as HTMLInputElement).value);
constrainOffset();
}
function confirm() {
if (!img) return;
const offscreen = document.createElement('canvas');
offscreen.width = OUTPUT_SIZE;
offscreen.height = OUTPUT_SIZE;
const ctx = offscreen.getContext('2d')!;
// Clip to circle
ctx.beginPath();
ctx.arc(OUTPUT_SIZE / 2, OUTPUT_SIZE / 2, OUTPUT_SIZE / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
// Map the viewport circle to the output canvas
const ratio = OUTPUT_SIZE / (VIEWPORT_RADIUS * 2);
const w = img.width * scale * ratio;
const h = img.height * scale * ratio;
const x = OUTPUT_SIZE / 2 - w / 2 + offsetX * ratio;
const y = OUTPUT_SIZE / 2 - h / 2 + offsetY * ratio;
ctx.drawImage(img, x, y, w, h);
offscreen.toBlob(
(blob) => {
if (blob) onconfirm(blob);
},
'image/webp',
0.85
);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onkeydown={(e) => e.key === 'Escape' && oncancel()}>
<div
class="bg-white dark:bg-gray-900 rounded-xl shadow-xl p-6 mx-4 max-w-sm w-full"
role="dialog"
aria-modal="true"
aria-labelledby="crop-title"
use:trapFocus
>
<h2 id="crop-title" class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Crop Avatar</h2>
<div class="flex justify-center mb-4">
<canvas
bind:this={canvas}
width={CANVAS_SIZE}
height={CANVAS_SIZE}
class="rounded-lg cursor-grab active:cursor-grabbing max-w-full"
style="touch-action: none; width: {CANVAS_SIZE}px; height: {CANVAS_SIZE}px;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
></canvas>
</div>
<div class="flex items-center gap-3 mb-5">
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd" />
</svg>
<input
type="range"
min={minScale}
max={minScale * 4}
step={0.01}
value={scale}
oninput={onScaleChange}
class="flex-1"
aria-label="Zoom"
/>
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex justify-end gap-2">
<button
onclick={oncancel}
class="rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
Cancel
</button>
<button
onclick={confirm}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition"
>
Save
</button>
</div>
</div>
</div>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
let {
label,
value = $bindable(''),
gradient = false,
onchange
}: {
label: string;
value: string;
gradient?: boolean;
onchange?: (value: string) => void;
} = $props();
let mode = $state<'color' | 'gradient'>(
value?.startsWith('linear-gradient') || value?.startsWith('radial-gradient') ? 'gradient' : 'color'
);
let color = $state(mode === 'color' ? (value || '#000000') : '#000000');
let gradStart = $state('#0079bf');
let gradEnd = $state('#6366f1');
let gradDirection = $state('to right');
// Parse existing gradient value
if (mode === 'gradient' && value) {
const match = value.match(/linear-gradient\(([^,]+),\s*(#[0-9a-fA-F]{6}),\s*(#[0-9a-fA-F]{6})\)/);
if (match) {
gradDirection = match[1].trim();
gradStart = match[2];
gradEnd = match[3];
}
}
function emitColor() {
value = color;
onchange?.(value);
}
function emitGradient() {
value = `linear-gradient(${gradDirection}, ${gradStart}, ${gradEnd})`;
onchange?.(value);
}
function switchMode(newMode: 'color' | 'gradient') {
mode = newMode;
if (newMode === 'color') {
emitColor();
} else {
emitGradient();
}
}
function clear() {
value = '';
onchange?.('');
}
</script>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label>
{#if value}
<button onclick={clear} class="text-[10px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Reset</button>
{/if}
</div>
{#if gradient}
<div class="flex gap-1 mb-1.5">
<button
onclick={() => switchMode('color')}
class="px-2 py-0.5 text-[10px] rounded transition {mode === 'color' ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}"
>
Solid
</button>
<button
onclick={() => switchMode('gradient')}
class="px-2 py-0.5 text-[10px] rounded transition {mode === 'gradient' ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}"
>
Gradient
</button>
</div>
{/if}
{#if mode === 'color' || !gradient}
<div class="flex items-center gap-2">
<input
type="color"
bind:value={color}
oninput={emitColor}
class="h-8 w-10 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0.5"
/>
<input
type="text"
bind:value={color}
oninput={emitColor}
placeholder="#000000"
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-xs font-mono focus:border-[var(--color-primary)] focus:outline-none"
/>
</div>
{:else}
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<input
type="color"
bind:value={gradStart}
oninput={emitGradient}
class="h-7 w-8 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0.5"
/>
<span class="text-[10px] text-gray-400">to</span>
<input
type="color"
bind:value={gradEnd}
oninput={emitGradient}
class="h-7 w-8 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0.5"
/>
<select
bind:value={gradDirection}
onchange={emitGradient}
class="text-[10px] rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-1 py-0.5"
>
<option value="to right">Left → Right</option>
<option value="to left">Right → Left</option>
<option value="to bottom">Top → Bottom</option>
<option value="to top">Bottom → Top</option>
<option value="to bottom right">Diagonal ↘</option>
<option value="135deg">Diagonal ↙</option>
</select>
</div>
<div class="h-6 rounded" style="background: {value}"></div>
</div>
{/if}
{#if value && mode === 'color'}
<div class="h-4 w-full rounded" style="background: {value}"></div>
{/if}
</div>
+3 -2
View File
@@ -3,6 +3,7 @@
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js'; import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { api } from '$lib/utils/api.js'; import { api } from '$lib/utils/api.js';
import Avatar from '$lib/components/Avatar.svelte';
type Comment = { type Comment = {
id: string; id: string;
@@ -109,8 +110,8 @@
<div class="space-y-3"> <div class="space-y-3">
{#each localComments as comment} {#each localComments as comment}
<div class="flex gap-2"> <div class="flex gap-2">
<div class="w-7 h-7 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center flex-shrink-0 mt-0.5"> <div class="flex-shrink-0 mt-0.5">
{comment.user.name[0].toUpperCase()} <Avatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} size="md" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5"> <div class="flex items-center gap-2 mb-0.5">
@@ -0,0 +1,96 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js';
let {
label,
slot,
currentUrl = null,
onchange
}: {
label: string;
slot: string;
currentUrl: string | null;
onchange?: (url: string | null) => void;
} = $props();
let uploading = $state(false);
let fileInput: HTMLInputElement;
async function handleUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
uploading = true;
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
try {
const res = await fetch('/api/admin/branding/images', {
method: 'POST',
body: formData
});
if (!res.ok) {
const data = await res.json().catch(() => ({ message: 'Upload failed' }));
toast.error(data.message || 'Upload failed');
return;
}
const data = await res.json();
currentUrl = data.imageUrl;
onchange?.(data.imageUrl);
toast.success('Image uploaded');
} catch {
toast.error('Upload failed');
} finally {
uploading = false;
input.value = '';
}
}
async function removeImage() {
const { ok } = await api('/api/admin/branding/images', {
method: 'DELETE',
body: JSON.stringify({ slot })
});
if (ok) {
currentUrl = null;
onchange?.(null);
toast.success('Image removed');
}
}
</script>
<div class="space-y-1.5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label>
<div class="flex items-center gap-2">
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleUpload}
class="hidden"
/>
<button
onclick={() => fileInput.click()}
disabled={uploading}
class="rounded bg-gray-100 dark:bg-gray-700 px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition disabled:opacity-50"
>
{uploading ? 'Uploading...' : 'Upload image'}
</button>
{#if currentUrl}
<button
onclick={removeImage}
class="text-xs text-[var(--color-danger)] hover:underline"
>
Remove
</button>
{/if}
</div>
{#if currentUrl}
<div class="mt-1">
<img src={currentUrl} alt="Preview" class="h-16 rounded border border-gray-200 dark:border-gray-600 object-cover" />
</div>
{/if}
</div>
+2 -3
View File
@@ -56,9 +56,8 @@
} }
} }
onMount(async () => { onMount(() => {
await fetchNotifications(); fetchNotifications().then(() => { loading = false; });
loading = false;
const socket = getSocket(); const socket = getSocket();
socket.on('notification:new', onNewNotification); socket.on('notification:new', onNewNotification);
+1 -1
View File
@@ -21,7 +21,7 @@ export async function logActivity(tx: TxClient, opts: LogActivityOptions) {
userId: opts.userId, userId: opts.userId,
action: opts.action, action: opts.action,
cardId: opts.cardId ?? undefined, cardId: opts.cardId ?? undefined,
metadata: opts.metadata ?? undefined metadata: (opts.metadata as Prisma.InputJsonValue) ?? undefined
} }
}); });
} }
+2 -1
View File
@@ -35,7 +35,8 @@ export async function validateSession(sessionId: string) {
name: true, name: true,
globalRole: true, globalRole: true,
tenantRole: true, tenantRole: true,
tenantId: true tenantId: true,
avatarUrl: true
} }
} }
} }
+5 -2
View File
@@ -1,4 +1,5 @@
import { prisma } from './db.js'; import { prisma } from './db.js';
import type { TenantThemeData } from '../../app.d.js';
const tenantCache = new Map<string, { tenant: TenantInfo; expiresAt: number }>(); const tenantCache = new Map<string, { tenant: TenantInfo; expiresAt: number }>();
const CACHE_TTL = 60_000; // 1 minute const CACHE_TTL = 60_000; // 1 minute
@@ -7,6 +8,7 @@ export interface TenantInfo {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
theme?: TenantThemeData | null;
} }
export async function resolveTenant(hostname: string): Promise<TenantInfo | null> { export async function resolveTenant(hostname: string): Promise<TenantInfo | null> {
@@ -22,14 +24,15 @@ export async function resolveTenant(hostname: string): Promise<TenantInfo | null
// Look up existing hostname mapping // Look up existing hostname mapping
const record = await prisma.tenantHostname.findUnique({ const record = await prisma.tenantHostname.findUnique({
where: { hostname: host }, where: { hostname: host },
include: { tenant: true } include: { tenant: { include: { theme: true } } }
}); });
if (record) { if (record) {
const tenant: TenantInfo = { const tenant: TenantInfo = {
id: record.tenant.id, id: record.tenant.id,
name: record.tenant.name, name: record.tenant.name,
slug: record.tenant.slug slug: record.tenant.slug,
theme: record.tenant.theme ?? null
}; };
tenantCache.set(host, { tenant, expiresAt: Date.now() + CACHE_TTL }); tenantCache.set(host, { tenant, expiresAt: Date.now() + CACHE_TTL });
return tenant; return tenant;
+135
View File
@@ -0,0 +1,135 @@
import type { TenantThemeData } from '../../app.d.js';
/**
* Maps theme fields to CSS custom property names.
* Light-prefixed fields go into :root {}, dark-prefixed into html.dark {}.
*/
const FIELD_MAP: Record<string, string> = {
// 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 <style> block with CSS custom properties from a TenantTheme.
* Returns empty string if theme is null or all fields are null.
*/
export function generateTenantCSS(theme: TenantThemeData | null | undefined): string {
if (!theme) return '';
const lightVars: string[] = [];
const darkVars: string[] = [];
for (const [field, cssVar] of Object.entries(FIELD_MAP)) {
const value = (theme as any)[field];
if (value == null) continue;
const isDark = field.startsWith('dark');
const target = isDark ? darkVars : lightVars;
target.push(` ${cssVar}: ${value};`);
}
// Handle opacity fields — apply to the bg color as rgba
if (theme.lightColumnOpacity != null && theme.lightColumnBg) {
const idx = lightVars.findIndex((v) => v.includes('--color-column-bg:'));
if (idx !== -1 && theme.lightColumnBg.startsWith('#')) {
lightVars[idx] = ` --color-column-bg: ${hexToRgba(theme.lightColumnBg, theme.lightColumnOpacity)};`;
}
}
if (theme.lightCardOpacity != null && theme.lightCardBg) {
const idx = lightVars.findIndex((v) => v.includes('--color-card-bg:'));
if (idx !== -1 && theme.lightCardBg.startsWith('#')) {
lightVars[idx] = ` --color-card-bg: ${hexToRgba(theme.lightCardBg, theme.lightCardOpacity)};`;
}
}
if (theme.darkColumnOpacity != null && theme.darkColumnBg) {
const idx = darkVars.findIndex((v) => v.includes('--color-column-bg:'));
if (idx !== -1 && theme.darkColumnBg.startsWith('#')) {
darkVars[idx] = ` --color-column-bg: ${hexToRgba(theme.darkColumnBg, theme.darkColumnOpacity)};`;
}
}
if (theme.darkCardOpacity != null && theme.darkCardBg) {
const idx = darkVars.findIndex((v) => v.includes('--color-card-bg:'));
if (idx !== -1 && theme.darkCardBg.startsWith('#')) {
darkVars[idx] = ` --color-card-bg: ${hexToRgba(theme.darkCardBg, theme.darkCardOpacity)};`;
}
}
// Handle background images
if (theme.lightPageBgImage) {
lightVars.push(` --page-bg-image: url('${theme.lightPageBgImage}');`);
}
if (theme.lightNavBgImage) {
lightVars.push(` --nav-bg-image: url('${theme.lightNavBgImage}');`);
}
if (theme.darkPageBgImage) {
darkVars.push(` --page-bg-image: url('${theme.darkPageBgImage}');`);
}
if (theme.darkNavBgImage) {
darkVars.push(` --nav-bg-image: url('${theme.darkNavBgImage}');`);
}
// Handle column/card opacity for backdrop-blur
if (theme.lightColumnOpacity != null && theme.lightColumnOpacity < 1) {
lightVars.push(` --column-backdrop-blur: blur(8px);`);
}
if (theme.lightCardOpacity != null && theme.lightCardOpacity < 1) {
lightVars.push(` --card-backdrop-blur: blur(8px);`);
}
if (theme.darkColumnOpacity != null && theme.darkColumnOpacity < 1) {
darkVars.push(` --column-backdrop-blur: blur(8px);`);
}
if (theme.darkCardOpacity != null && theme.darkCardOpacity < 1) {
darkVars.push(` --card-backdrop-blur: blur(8px);`);
}
if (lightVars.length === 0 && darkVars.length === 0) return '';
let css = '<style id="tenant-theme">\n';
if (lightVars.length > 0) {
css += `:root {\n${lightVars.join('\n')}\n}\n`;
}
if (darkVars.length > 0) {
css += `html.dark {\n${darkVars.join('\n')}\n}\n`;
}
css += '</style>';
return css;
}
+119
View File
@@ -0,0 +1,119 @@
import type { TenantThemeData } from '../../app.d.js';
const LIGHT_FIELD_MAP: Record<string, string> = {
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<string, string> = {
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;
}
+11 -4
View File
@@ -8,6 +8,7 @@
import SearchBar from '$lib/components/SearchBar.svelte'; import SearchBar from '$lib/components/SearchBar.svelte';
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.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(); let { children, data }: { children: Snippet; data: { user: any; tenant: any; theme?: string } } = $props();
@@ -23,8 +24,8 @@
}); });
</script> </script>
<div class="min-h-screen bg-gray-50 dark:bg-gray-950"> <div class="min-h-screen" style="background: var(--color-page-bg); background-image: var(--page-bg-image, none); background-size: cover; background-position: center;">
<nav class="bg-[var(--color-primary)] shadow-sm"> <nav class="shadow-sm" style="background: var(--color-nav-bg); background-image: var(--nav-bg-image, none); background-size: cover; background-position: center;">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-14 items-center justify-between"> <div class="flex h-14 items-center justify-between">
<a href="/" class="flex items-center gap-2 text-white font-bold text-lg"> <a href="/" class="flex items-center gap-2 text-white font-bold text-lg">
@@ -78,7 +79,10 @@
Admin Admin
</a> </a>
{/if} {/if}
<a href="/account" class="text-white/80 text-sm hover:text-white transition">{data.user.name}</a> <a href="/account" class="flex items-center gap-1.5 text-white/80 text-sm hover:text-white transition">
<Avatar name={data.user.name} avatarUrl={data.user.avatarUrl} size="xs" class="border border-white/30" />
{data.user.name}
</a>
<NotificationBell /> <NotificationBell />
<button <button
onclick={async () => { onclick={async () => {
@@ -150,7 +154,10 @@
<SearchBar /> <SearchBar />
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<a href="/account" class="text-white/80 text-sm py-1 hover:text-white transition" onclick={() => (mobileMenuOpen = false)}>{data.user.name}</a> <a href="/account" class="flex items-center gap-1.5 text-white/80 text-sm py-1 hover:text-white transition" onclick={() => (mobileMenuOpen = false)}>
<Avatar name={data.user.name} avatarUrl={data.user.avatarUrl} size="xs" class="border border-white/30" />
{data.user.name}
</a>
{#if data.user.globalRole === 'SITE_ADMIN'} {#if data.user.globalRole === 'SITE_ADMIN'}
<a <a
href="/admin/site" href="/admin/site"
+103
View File
@@ -2,6 +2,8 @@
import { api } from '$lib/utils/api.js'; import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js'; import { toast } from '$lib/stores/toasts.svelte.js';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import Avatar from '$lib/components/Avatar.svelte';
import AvatarCropEditor from '$lib/components/AvatarCropEditor.svelte';
let { data } = $props(); let { data } = $props();
@@ -10,6 +12,12 @@
let email = $state(data.user.email); let email = $state(data.user.email);
let savingProfile = $state(false); let savingProfile = $state(false);
// Avatar
let avatarUrl = $state<string | null>(data.user.avatarUrl);
let cropFile = $state<File | null>(null);
let fileInput: HTMLInputElement;
let uploadingAvatar = $state(false);
// Password // Password
let currentPassword = $state(''); let currentPassword = $state('');
let newPassword = $state(''); let newPassword = $state('');
@@ -20,6 +28,44 @@
let sessionCount = $state(data.sessionCount); let sessionCount = $state(data.sessionCount);
let revokingSessions = $state(false); let revokingSessions = $state(false);
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
cropFile = file;
input.value = '';
}
}
async function onCropConfirm(blob: Blob) {
cropFile = null;
uploadingAvatar = true;
const formData = new FormData();
formData.append('avatar', blob, 'avatar.webp');
const { ok, data: result } = await api('/api/account/avatar', {
method: 'POST',
body: formData,
headers: {} // let browser set content-type with boundary
});
if (ok) {
avatarUrl = result.avatarUrl + '?t=' + Date.now();
toast.success('Avatar updated');
await invalidateAll();
}
uploadingAvatar = false;
}
async function removeAvatar() {
uploadingAvatar = true;
const { ok } = await api('/api/account/avatar', { method: 'DELETE' });
if (ok) {
avatarUrl = null;
toast.success('Avatar removed');
await invalidateAll();
}
uploadingAvatar = false;
}
async function saveProfile() { async function saveProfile() {
savingProfile = true; savingProfile = true;
try { try {
@@ -88,6 +134,55 @@
<section class="mb-8"> <section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Profile</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Profile</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4"> <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<!-- Avatar -->
<div class="flex items-center gap-4 mb-4 pb-4 border-b border-gray-100 dark:border-gray-800">
<button
onclick={() => fileInput.click()}
class="relative group rounded-full flex-shrink-0"
disabled={uploadingAvatar}
type="button"
>
{#if avatarUrl}
<img
src={avatarUrl}
alt={name}
class="w-16 h-16 rounded-full object-cover"
onerror={() => (avatarUrl = null)}
/>
{:else}
<div class="w-16 h-16 rounded-full bg-[var(--color-primary)] text-white text-xl flex items-center justify-center font-medium">
{name[0]?.toUpperCase() ?? '?'}
</div>
{/if}
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<svg class="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
</div>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden"
onchange={onFileSelect}
/>
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">Profile photo</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Click to upload a new photo</p>
{#if avatarUrl}
<button
onclick={removeAvatar}
disabled={uploadingAvatar}
class="text-xs text-red-500 hover:text-red-600 mt-1 disabled:opacity-50"
type="button"
>
Remove photo
</button>
{/if}
</div>
</div>
<form onsubmit={async (e) => { e.preventDefault(); await saveProfile(); }} class="space-y-3"> <form onsubmit={async (e) => { e.preventDefault(); await saveProfile(); }} class="space-y-3">
<div> <div>
<label for="profile-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label> <label for="profile-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
@@ -187,3 +282,11 @@
</div> </div>
</section> </section>
</div> </div>
{#if cropFile}
<AvatarCropEditor
file={cropFile}
onconfirm={onCropConfirm}
oncancel={() => (cropFile = null)}
/>
{/if}
+12 -1
View File
@@ -121,7 +121,18 @@
</svelte:head> </svelte:head>
<div class="mx-auto max-w-5xl px-4 py-8"> <div class="mx-auto max-w-5xl px-4 py-8">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Workspace Admin</h1> <div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Workspace Admin</h1>
<a
href="/admin/branding"
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition flex items-center gap-1.5"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2.121 2.121 0 00-3-3L7 11.243V14.5a.5.5 0 00.5.5h2.257z" clip-rule="evenodd" />
</svg>
Branding
</a>
</div>
<!-- Users --> <!-- Users -->
<section class="mb-8"> <section class="mb-8">
+16
View File
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js';
export const load: PageServerLoad = async ({ locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const theme = await withTenant(tenant.id, (tx) =>
tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } })
);
return { theme: theme ?? null, tenantName: tenant.name };
};
+369
View File
@@ -0,0 +1,369 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js';
import { getTheme } from '$lib/stores/theme.svelte.js';
import { applyThemePreview, clearThemePreview } from '$lib/utils/theme-preview.js';
import ColorPicker from '$lib/components/ColorPicker.svelte';
import ImageUploadField from '$lib/components/ImageUploadField.svelte';
import type { TenantThemeData } from '../../../app.d.js';
let { data } = $props();
let activeTab = $state<'light' | 'dark'>('light');
let saving = $state(false);
// Draft theme — initialize from server data
let draft = $state<TenantThemeData>(data.theme ? { ...data.theme } : {});
const currentTheme = $derived(getTheme());
// Live preview — apply on every change
$effect(() => {
// Re-run when draft changes or tab changes
const mode = currentTheme === 'dark' ? 'dark' : 'light';
applyThemePreview(draft, mode);
});
onDestroy(() => {
clearThemePreview();
});
// Helpers to get/set field values by prefix + name
function getField(name: string): any {
const key = `${activeTab}${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TenantThemeData;
return (draft as any)[key] ?? '';
}
function setField(name: string, value: any) {
const key = `${activeTab}${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TenantThemeData;
(draft as any)[key] = value === '' ? null : value;
// Trigger reactivity
draft = { ...draft };
}
function getFieldKey(name: string): string {
return `${activeTab}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
}
async function save() {
saving = true;
const { ok } = await api('/api/admin/branding', {
method: 'PUT',
body: JSON.stringify(draft)
});
if (ok) {
toast.success('Theme saved');
await invalidateAll();
}
saving = false;
}
async function resetToDefaults() {
// Set all fields to null
const empty: Record<string, null> = {};
const fields = [
'lightPageBg', 'lightNavBg', 'lightColumnBg', 'lightColumnBorder', 'lightColumnText',
'lightCardBg', 'lightCardBorder', 'lightCardText', 'lightCardRadius',
'lightColumnOpacity', 'lightCardOpacity',
'lightPrimary', 'lightPrimaryHover', 'lightDanger', 'lightDangerHover', 'lightSuccess', 'lightWarning',
'darkPageBg', 'darkNavBg', 'darkColumnBg', 'darkColumnBorder', 'darkColumnText',
'darkCardBg', 'darkCardBorder', 'darkCardText', 'darkCardRadius',
'darkColumnOpacity', 'darkCardOpacity',
'darkPrimary', 'darkPrimaryHover', 'darkDanger', 'darkDangerHover', 'darkSuccess', 'darkWarning'
];
for (const f of fields) empty[f] = null;
saving = true;
const { ok } = await api('/api/admin/branding', {
method: 'PUT',
body: JSON.stringify(empty)
});
if (ok) {
draft = {};
clearThemePreview();
toast.success('Theme reset to defaults');
await invalidateAll();
}
saving = false;
}
function opacityPercent(name: string): number {
const v = getField(name);
return v != null && v !== '' ? Math.round(v * 100) : 100;
}
function setOpacity(name: string, percent: number) {
setField(name, percent / 100);
}
function radiusPx(name: string): number {
const v = getField(name);
if (!v) return 8;
const match = String(v).match(/^(\d+)/);
return match ? parseInt(match[1]) : 8;
}
function setRadius(name: string, px: number) {
setField(name, `${px}px`);
}
</script>
<svelte:head>
<title>Branding - {data.tenantName}</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Branding</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Customize your workspace's appearance</p>
</div>
<a href="/admin" class="text-sm text-[var(--color-primary)] hover:underline">&larr; Back to Admin</a>
</div>
<!-- Mode tabs -->
<div class="flex gap-1 mb-6 bg-gray-100 dark:bg-gray-800 rounded-lg p-1 w-fit">
<button
onclick={() => (activeTab = 'light')}
class="px-4 py-1.5 text-sm rounded-md transition {activeTab === 'light' ? 'bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
>
Light Mode
</button>
<button
onclick={() => (activeTab = 'dark')}
class="px-4 py-1.5 text-sm rounded-md transition {activeTab === 'dark' ? 'bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
>
Dark Mode
</button>
</div>
<div class="space-y-6">
<!-- Page Background -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Page Background</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ColorPicker
label="Background color"
value={getField('pageBg') || ''}
gradient={true}
onchange={(v) => setField('pageBg', v)}
/>
<ImageUploadField
label="Background image"
slot="{activeTab}-page-bg"
currentUrl={getField('pageBgImage') || null}
onchange={(url) => setField('pageBgImage', url)}
/>
</div>
</section>
<!-- Navigation Bar -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Navigation Bar</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ColorPicker
label="Background color"
value={getField('navBg') || ''}
gradient={true}
onchange={(v) => setField('navBg', v)}
/>
<ImageUploadField
label="Background image"
slot="{activeTab}-nav-bg"
currentUrl={getField('navBgImage') || null}
onchange={(url) => setField('navBgImage', url)}
/>
</div>
</section>
<!-- Columns -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Columns</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<ColorPicker
label="Background"
value={getField('columnBg') || ''}
onchange={(v) => setField('columnBg', v)}
/>
<ColorPicker
label="Border"
value={getField('columnBorder') || ''}
onchange={(v) => setField('columnBorder', v)}
/>
<ColorPicker
label="Text"
value={getField('columnText') || ''}
onchange={(v) => setField('columnText', v)}
/>
</div>
<div class="mt-4 space-y-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">
Opacity: {opacityPercent('columnOpacity')}%
</label>
<input
type="range"
min="0"
max="100"
value={opacityPercent('columnOpacity')}
oninput={(e) => setOpacity('columnOpacity', parseInt(e.currentTarget.value))}
class="w-full max-w-xs accent-[var(--color-primary)]"
/>
<p class="text-[10px] text-gray-400 dark:text-gray-500">Below 100% enables glass blur effect</p>
</div>
</section>
<!-- Cards -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Cards</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<ColorPicker
label="Background"
value={getField('cardBg') || ''}
onchange={(v) => setField('cardBg', v)}
/>
<ColorPicker
label="Border"
value={getField('cardBorder') || ''}
onchange={(v) => setField('cardBorder', v)}
/>
<ColorPicker
label="Text"
value={getField('cardText') || ''}
onchange={(v) => setField('cardText', v)}
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">
Opacity: {opacityPercent('cardOpacity')}%
</label>
<input
type="range"
min="0"
max="100"
value={opacityPercent('cardOpacity')}
oninput={(e) => setOpacity('cardOpacity', parseInt(e.currentTarget.value))}
class="w-full max-w-xs accent-[var(--color-primary)]"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">
Corner radius: {radiusPx('cardRadius')}px
</label>
<input
type="range"
min="0"
max="24"
value={radiusPx('cardRadius')}
oninput={(e) => setRadius('cardRadius', parseInt(e.currentTarget.value))}
class="w-full max-w-xs accent-[var(--color-primary)]"
/>
</div>
</div>
</section>
<!-- Accent Colors -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Accent Colors</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<ColorPicker
label="Primary"
value={getField('primary') || ''}
onchange={(v) => setField('primary', v)}
/>
<ColorPicker
label="Primary hover"
value={getField('primaryHover') || ''}
onchange={(v) => setField('primaryHover', v)}
/>
<ColorPicker
label="Danger"
value={getField('danger') || ''}
onchange={(v) => setField('danger', v)}
/>
<ColorPicker
label="Danger hover"
value={getField('dangerHover') || ''}
onchange={(v) => setField('dangerHover', v)}
/>
<ColorPicker
label="Success"
value={getField('success') || ''}
onchange={(v) => setField('success', v)}
/>
<ColorPicker
label="Warning"
value={getField('warning') || ''}
onchange={(v) => setField('warning', v)}
/>
</div>
</section>
<!-- Live Preview -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Preview</h2>
<div
class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-600"
style="background: {getField('pageBg') || (activeTab === 'light' ? '#f9fafb' : '#030712')}; min-height: 200px;"
>
<!-- Mini nav -->
<div
class="h-8 flex items-center px-3"
style="background: {getField('navBg') || (activeTab === 'light' ? '#0079bf' : '#3b82f6')};"
>
<span class="text-white text-xs font-bold">Pounce</span>
<div class="ml-auto flex gap-1">
<div class="w-5 h-4 rounded" style="background: rgba(255,255,255,0.2);"></div>
<div class="w-5 h-4 rounded" style="background: rgba(255,255,255,0.2);"></div>
</div>
</div>
<!-- Mini board -->
<div class="flex gap-2 p-3">
{#each ['To Do', 'In Progress', 'Done'] as colTitle}
<div
class="w-28 rounded-lg p-2"
style="background: {getField('columnBg') || (activeTab === 'light' ? '#ebecf0' : '#1f2937')}; border: 1px solid {getField('columnBorder') || 'transparent'}; color: {getField('columnText') || 'inherit'}; opacity: {getField('columnOpacity') != null && getField('columnOpacity') !== '' ? getField('columnOpacity') : 1};"
>
<div class="text-[10px] font-semibold mb-1.5" style="color: {getField('columnText') || (activeTab === 'light' ? '#374151' : '#d1d5db')};">{colTitle}</div>
{#each [1, 2] as _}
<div
class="mb-1 p-1.5 shadow-sm"
style="background: {getField('cardBg') || (activeTab === 'light' ? '#ffffff' : '#374151')}; border: 1px solid {getField('cardBorder') || (activeTab === 'light' ? '#e5e7eb' : '#4b5563')}; border-radius: {getField('cardRadius') || '0.5rem'}; opacity: {getField('cardOpacity') != null && getField('cardOpacity') !== '' ? getField('cardOpacity') : 1};"
>
<div class="h-1.5 rounded" style="background: {getField('cardText') || (activeTab === 'light' ? '#374151' : '#d1d5db')}; width: 60%; opacity: 0.5;"></div>
</div>
{/each}
</div>
{/each}
</div>
<!-- Mini button row -->
<div class="flex gap-2 px-3 pb-3">
<div class="rounded px-2 py-1 text-[10px] text-white" style="background: {getField('primary') || (activeTab === 'light' ? '#0079bf' : '#3b82f6')};">Primary</div>
<div class="rounded px-2 py-1 text-[10px] text-white" style="background: {getField('danger') || (activeTab === 'light' ? '#eb5a46' : '#ef4444')};">Danger</div>
<div class="rounded px-2 py-1 text-[10px] text-white" style="background: {getField('success') || (activeTab === 'light' ? '#61bd4f' : '#22c55e')};">Success</div>
<div class="rounded px-2 py-1 text-[10px] text-gray-800" style="background: {getField('warning') || (activeTab === 'light' ? '#f2d600' : '#eab308')};">Warning</div>
</div>
</div>
</section>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 mt-6">
<button
onclick={save}
disabled={saving}
class="rounded-lg bg-[var(--color-primary)] px-5 py-2 text-sm font-medium text-white hover:opacity-90 transition disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
<button
onclick={resetToDefaults}
disabled={saving}
class="rounded-lg border border-gray-300 dark:border-gray-600 px-5 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition disabled:opacity-50"
>
Reset to Defaults
</button>
</div>
</div>
+57
View File
@@ -0,0 +1,57 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withBypassRLS } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
import { writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path';
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const UPLOAD_ROOT = '/app/uploads';
export const POST: RequestHandler = async ({ request, locals }) => {
requireAuth(locals.user);
const formData = await request.formData();
const file = formData.get('avatar') as File | null;
if (!file) throw error(400, 'No file provided');
if (file.size > MAX_SIZE) throw error(400, 'File too large (max 2MB)');
if (!file.type.startsWith('image/')) throw error(400, 'File must be an image');
const dir = join(UPLOAD_ROOT, 'avatars');
await mkdir(dir, { recursive: true });
const filepath = join(dir, `${locals.user.id}.webp`);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filepath, buffer);
const avatarUrl = `/api/avatars/${locals.user.id}`;
await withBypassRLS(async (tx) => {
await tx.user.update({
where: { id: locals.user!.id },
data: { avatarUrl }
});
});
return json({ avatarUrl });
};
export const DELETE: RequestHandler = async ({ locals }) => {
requireAuth(locals.user);
const filepath = join(UPLOAD_ROOT, 'avatars', `${locals.user.id}.webp`);
try {
await unlink(filepath);
} catch {
// File may already be gone
}
await withBypassRLS(async (tx) => {
await tx.user.update({
where: { id: locals.user!.id },
data: { avatarUrl: null }
});
});
return json({ ok: true });
};
+100
View File
@@ -0,0 +1,100 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js';
import { clearTenantCache } from '$lib/server/tenant.js';
const VALID_COLOR = /^(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|rgba?\(.+\)|hsla?\(.+\)|transparent|inherit)$/;
const VALID_BG = /^(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|rgba?\(.+\)|hsla?\(.+\)|transparent|linear-gradient\(.+\)|radial-gradient\(.+\))$/;
const STRING_FIELDS = [
'lightPageBg', 'lightNavBg', 'lightColumnBg', 'lightColumnBorder', 'lightColumnText',
'lightCardBg', 'lightCardBorder', 'lightCardText', 'lightCardRadius',
'lightPrimary', 'lightPrimaryHover', 'lightDanger', 'lightDangerHover', 'lightSuccess', 'lightWarning',
'darkPageBg', 'darkNavBg', 'darkColumnBg', 'darkColumnBorder', 'darkColumnText',
'darkCardBg', 'darkCardBorder', 'darkCardText', 'darkCardRadius',
'darkPrimary', 'darkPrimaryHover', 'darkDanger', 'darkDangerHover', 'darkSuccess', 'darkWarning'
] as const;
const BG_FIELDS = new Set([
'lightPageBg', 'lightNavBg', 'lightColumnBg', 'lightCardBg',
'darkPageBg', 'darkNavBg', 'darkColumnBg', 'darkCardBg'
]);
const FLOAT_FIELDS = [
'lightColumnOpacity', 'lightCardOpacity',
'darkColumnOpacity', 'darkCardOpacity'
] as const;
export const GET: RequestHandler = async ({ locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const theme = await withTenant(tenant.id, (tx) =>
tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } })
);
return json({ theme: theme ?? null });
};
export const PUT: RequestHandler = async ({ request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const body = await request.json();
const data: Record<string, any> = {};
// Validate and pick string fields
for (const field of STRING_FIELDS) {
if (field in body) {
const val = body[field];
if (val === null || val === '') {
data[field] = null;
} else if (typeof val === 'string') {
// Radius fields: allow px/rem/em values
if (field.endsWith('Radius')) {
if (!/^\d+(\.\d+)?(px|rem|em)$/.test(val)) {
throw error(400, `Invalid radius value for ${field}`);
}
} else if (field.endsWith('Text') && (val === 'inherit' || VALID_COLOR.test(val))) {
// text color — allow inherit
} else if (BG_FIELDS.has(field)) {
if (!VALID_BG.test(val)) {
throw error(400, `Invalid background value for ${field}`);
}
} else if (!VALID_COLOR.test(val)) {
throw error(400, `Invalid color value for ${field}`);
}
data[field] = val;
}
}
}
// Validate float fields (opacity)
for (const field of FLOAT_FIELDS) {
if (field in body) {
const val = body[field];
if (val === null) {
data[field] = null;
} else if (typeof val === 'number' && val >= 0 && val <= 1) {
data[field] = val;
} else {
throw error(400, `Invalid opacity value for ${field} (must be 0-1)`);
}
}
}
const theme = await withTenant(tenant.id, (tx) =>
tx.tenantTheme.upsert({
where: { tenantId: tenant.id },
create: { tenantId: tenant.id, ...data },
update: data
})
);
clearTenantCache();
return json({ theme });
};
@@ -0,0 +1,87 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js';
import { clearTenantCache } from '$lib/server/tenant.js';
import { writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path';
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const UPLOAD_ROOT = '/app/uploads';
const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg'] as const;
type Slot = typeof VALID_SLOTS[number];
const SLOT_TO_FIELD: Record<Slot, string> = {
'light-page-bg': 'lightPageBgImage',
'light-nav-bg': 'lightNavBgImage',
'dark-page-bg': 'darkPageBgImage',
'dark-nav-bg': 'darkNavBgImage'
};
export const POST: RequestHandler = async ({ request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const formData = await request.formData();
const file = formData.get('file') as File | null;
const slot = formData.get('slot') as string | null;
if (!file) throw error(400, 'No file provided');
if (!slot || !VALID_SLOTS.includes(slot as Slot)) throw error(400, 'Invalid slot');
if (file.size > MAX_SIZE) throw error(400, 'File too large (max 5MB)');
if (!file.type.startsWith('image/')) throw error(400, 'File must be an image');
const dir = join(UPLOAD_ROOT, 'branding', tenant.id);
await mkdir(dir, { recursive: true });
const filepath = join(dir, `${slot}.webp`);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filepath, buffer);
const imageUrl = `/api/branding/${tenant.id}/${slot}`;
const field = SLOT_TO_FIELD[slot as Slot];
await withTenant(tenant.id, (tx) =>
tx.tenantTheme.upsert({
where: { tenantId: tenant.id },
create: { tenantId: tenant.id, [field]: imageUrl },
update: { [field]: imageUrl }
})
);
clearTenantCache();
return json({ imageUrl });
};
export const DELETE: RequestHandler = async ({ request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const body = await request.json();
const slot = body.slot as string;
if (!slot || !VALID_SLOTS.includes(slot as Slot)) throw error(400, 'Invalid slot');
const filepath = join(UPLOAD_ROOT, 'branding', tenant.id, `${slot}.webp`);
try {
await unlink(filepath);
} catch {
// File may already be gone
}
const field = SLOT_TO_FIELD[slot as Slot];
await withTenant(tenant.id, (tx) =>
tx.tenantTheme.upsert({
where: { tenantId: tenant.id },
create: { tenantId: tenant.id, [field]: null },
update: { [field]: null }
})
);
clearTenantCache();
return json({ ok: true });
};
@@ -0,0 +1,25 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
const UPLOAD_ROOT = '/app/uploads';
export const GET: RequestHandler = async ({ params }) => {
const filepath = join(UPLOAD_ROOT, 'avatars', `${params.userId}.webp`);
let buffer: Buffer;
try {
buffer = await readFile(filepath);
} catch {
throw error(404, 'Avatar not found');
}
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/webp',
'Content-Length': String(buffer.length),
'Cache-Control': 'public, max-age=300'
}
});
};
@@ -0,0 +1,30 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
const UPLOAD_ROOT = '/app/uploads';
const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg'];
export const GET: RequestHandler = async ({ params }) => {
if (!VALID_SLOTS.includes(params.slot)) {
throw error(404, 'Not found');
}
const filepath = join(UPLOAD_ROOT, 'branding', params.tenantId, `${params.slot}.webp`);
let buffer: Buffer;
try {
buffer = await readFile(filepath);
} catch {
throw error(404, 'Image not found');
}
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/webp',
'Content-Length': String(buffer.length),
'Cache-Control': 'public, max-age=300'
}
});
};
+2 -6
View File
@@ -4,6 +4,7 @@
import { replaceState } from '$app/navigation'; import { replaceState } from '$app/navigation';
import { api } from '$lib/utils/api.js'; import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js'; import { toast } from '$lib/stores/toasts.svelte.js';
import Avatar from '$lib/components/Avatar.svelte';
let { data } = $props(); let { data } = $props();
@@ -235,12 +236,7 @@
{#if board.members.length > 0} {#if board.members.length > 0}
<div class="mt-3 flex -space-x-1"> <div class="mt-3 flex -space-x-1">
{#each board.members.slice(0, 5) as member} {#each board.members.slice(0, 5) as member}
<div <Avatar name={member.user.name} avatarUrl={member.user.avatarUrl} size="sm" class="border-2 border-white dark:border-gray-900" title={member.user.name} />
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center border-2 border-white dark:border-gray-900"
title={member.user.name}
>
{member.user.name[0].toUpperCase()}
</div>
{/each} {/each}
{#if board.members.length > 5} {#if board.members.length > 5}
<div class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-xs flex items-center justify-center border-2 border-white dark:border-gray-900"> <div class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-xs flex items-center justify-center border-2 border-white dark:border-gray-900">
+8 -15
View File
@@ -10,6 +10,7 @@
import CardModal from '$lib/components/CardModal.svelte'; import CardModal from '$lib/components/CardModal.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte'; import ActivityFeed from '$lib/components/ActivityFeed.svelte';
import BoardFilters from '$lib/components/BoardFilters.svelte'; import BoardFilters from '$lib/components/BoardFilters.svelte';
import Avatar from '$lib/components/Avatar.svelte';
import type { FilterState } from '$lib/components/BoardFilters.svelte'; import type { FilterState } from '$lib/components/BoardFilters.svelte';
let { data } = $props(); let { data } = $props();
@@ -402,12 +403,7 @@
<span class="text-xs text-gray-400 dark:text-gray-500">Also here:</span> <span class="text-xs text-gray-400 dark:text-gray-500">Also here:</span>
<div class="flex -space-x-1.5"> <div class="flex -space-x-1.5">
{#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer} {#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer}
<div <Avatar name={viewer.name} avatarUrl={viewer.avatarUrl} size="sm" class="border-2 border-white dark:border-gray-900" title={viewer.name} />
class="w-6 h-6 rounded-full bg-emerald-500 text-white text-[11px] flex items-center justify-center border-2 border-white dark:border-gray-900"
title={viewer.name}
>
{viewer.name[0].toUpperCase()}
</div>
{/each} {/each}
</div> </div>
</div> </div>
@@ -468,7 +464,8 @@
{#each columns as column (column.id)} {#each columns as column (column.id)}
{@const visibleCards = filterCards(column.cards)} {@const visibleCards = filterCards(column.cards)}
<div <div
class="flex-shrink-0 w-64 sm:w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full" class="flex-shrink-0 w-64 sm:w-72 rounded-xl flex flex-col max-h-full"
style="background: var(--color-column-bg); border: 1px solid var(--color-column-border); color: var(--color-column-text); backdrop-filter: var(--column-backdrop-blur, none);"
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
> >
<!-- Column header --> <!-- Column header -->
@@ -536,7 +533,8 @@
{@const progress = getChecklistProgress(card)} {@const progress = getChecklistProgress(card)}
{@const dueUrgency = getDueUrgency(card.dueDate)} {@const dueUrgency = getDueUrgency(card.dueDate)}
<button <button
class="w-full text-left mb-2 rounded-lg bg-[var(--color-card-bg)] p-3 shadow-sm hover:shadow-md border border-gray-200 hover:border-gray-300 dark:border-gray-600 dark:hover:border-gray-500 transition cursor-pointer" class="w-full text-left mb-2 p-3 shadow-sm hover:shadow-md transition cursor-pointer"
style="background: var(--color-card-bg); border: 1px solid var(--color-card-border); color: var(--color-card-text); border-radius: var(--color-card-radius); backdrop-filter: var(--card-backdrop-blur, none);"
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
onclick={() => (selectedCard = card)} onclick={() => (selectedCard = card)}
> >
@@ -589,12 +587,7 @@
{#if card.assignees && card.assignees.length > 0} {#if card.assignees && card.assignees.length > 0}
<div class="flex -space-x-1 mt-2"> <div class="flex -space-x-1 mt-2">
{#each card.assignees.slice(0, 3) as assignee} {#each card.assignees.slice(0, 3) as assignee}
<div <Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" class="border border-white" title={assignee.user.name} />
class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center border border-white"
title={assignee.user.name}
>
{assignee.user.name[0].toUpperCase()}
</div>
{/each} {/each}
{#if card.assignees.length > 3} {#if card.assignees.length > 3}
<div <div
@@ -681,7 +674,7 @@
{#if data.canEdit} {#if data.canEdit}
<div class="flex-shrink-0 w-72"> <div class="flex-shrink-0 w-72">
{#if showAddColumn} {#if showAddColumn}
<div class="bg-[var(--color-column-bg)] rounded-xl p-3"> <div class="rounded-xl p-3" style="background: var(--color-column-bg);">
<form onsubmit={(e) => { e.preventDefault(); addColumn(); }}> <form onsubmit={(e) => { e.preventDefault(); addColumn(); }}>
<input <input
bind:value={addingColumnTitle} bind:value={addingColumnTitle}
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Avatar from '$lib/components/Avatar.svelte';
let { data } = $props(); let { data } = $props();
let members = $state(data.board.members.map((m: any) => ({ ...m }))); let members = $state(data.board.members.map((m: any) => ({ ...m })));
@@ -135,11 +137,7 @@
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 divide-y divide-gray-100 dark:divide-gray-800"> <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 divide-y divide-gray-100 dark:divide-gray-800">
{#each members as member (member.id)} {#each members as member (member.id)}
<div class="flex items-center gap-3 px-4 py-3"> <div class="flex items-center gap-3 px-4 py-3">
<div <Avatar name={member.user.name} avatarUrl={member.user.avatarUrl} size="lg" />
class="w-9 h-9 rounded-full bg-[var(--color-primary)] text-white text-sm flex items-center justify-center font-medium flex-shrink-0"
>
{member.user.name[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{member.user.name}</p> <p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{member.user.name}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{member.user.email}</p> <p class="text-xs text-gray-500 dark:text-gray-400 truncate">{member.user.email}</p>