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:
@@ -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")
|
||||
}
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
Vendored
+43
@@ -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;
|
||||
|
||||
+11
-2
@@ -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('<head>', `<head>${tenantCSS}`)
|
||||
: undefined
|
||||
});
|
||||
|
||||
logger.info({
|
||||
method: event.request.method,
|
||||
|
||||
@@ -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();
|
||||
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)}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<div
|
||||
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"
|
||||
title={activity.user.name}
|
||||
>
|
||||
{activity.user.name[0].toUpperCase()}
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<Avatar name={activity.user.name} avatarUrl={activity.user.avatarUrl} size="sm" title={activity.user.name} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-snug">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 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">
|
||||
{#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="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center">
|
||||
{assignee.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
<Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" />
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300">{assignee.user.name}</span>
|
||||
{#if canEdit}
|
||||
<button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5">
|
||||
@@ -91,9 +90,7 @@
|
||||
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' : ''}"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center">
|
||||
{member.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
<Avatar name={member.user.name} avatarUrl={member.user.avatarUrl} size="sm" />
|
||||
<span class="flex-1 text-left">{member.user.name}</span>
|
||||
{#if isAssigned(member.userId)}
|
||||
<svg class="w-4 h-4 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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,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 @@
|
||||
<div class="space-y-3">
|
||||
{#each localComments as comment}
|
||||
<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">
|
||||
{comment.user.name[0].toUpperCase()}
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<Avatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} size="md" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
@@ -56,9 +56,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchNotifications();
|
||||
loading = false;
|
||||
onMount(() => {
|
||||
fetchNotifications().then(() => { loading = false; });
|
||||
|
||||
const socket = getSocket();
|
||||
socket.on('notification:new', onNewNotification);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ export async function validateSession(sessionId: string) {
|
||||
name: true,
|
||||
globalRole: true,
|
||||
tenantRole: true,
|
||||
tenantId: true
|
||||
tenantId: true,
|
||||
avatarUrl: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from './db.js';
|
||||
import type { TenantThemeData } from '../../app.d.js';
|
||||
|
||||
const tenantCache = new Map<string, { tenant: TenantInfo; expiresAt: number }>();
|
||||
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<TenantInfo | null> {
|
||||
@@ -22,14 +24,15 @@ export async function resolveTenant(hostname: string): Promise<TenantInfo | null
|
||||
// Look up existing hostname mapping
|
||||
const record = await prisma.tenantHostname.findUnique({
|
||||
where: { hostname: host },
|
||||
include: { tenant: true }
|
||||
include: { tenant: { include: { theme: true } } }
|
||||
});
|
||||
|
||||
if (record) {
|
||||
const tenant: TenantInfo = {
|
||||
id: record.tenant.id,
|
||||
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 });
|
||||
return tenant;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<nav class="bg-[var(--color-primary)] shadow-sm">
|
||||
<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="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="flex h-14 items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2 text-white font-bold text-lg">
|
||||
@@ -78,7 +79,10 @@
|
||||
Admin
|
||||
</a>
|
||||
{/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 />
|
||||
<button
|
||||
onclick={async () => {
|
||||
@@ -150,7 +154,10 @@
|
||||
<SearchBar />
|
||||
</div>
|
||||
<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'}
|
||||
<a
|
||||
href="/admin/site"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { api } from '$lib/utils/api.js';
|
||||
import { toast } from '$lib/stores/toasts.svelte.js';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import AvatarCropEditor from '$lib/components/AvatarCropEditor.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -10,6 +12,12 @@
|
||||
let email = $state(data.user.email);
|
||||
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
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
@@ -20,6 +28,44 @@
|
||||
let sessionCount = $state(data.sessionCount);
|
||||
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() {
|
||||
savingProfile = true;
|
||||
try {
|
||||
@@ -88,6 +134,55 @@
|
||||
<section class="mb-8">
|
||||
<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">
|
||||
<!-- 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">
|
||||
<div>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if cropFile}
|
||||
<AvatarCropEditor
|
||||
file={cropFile}
|
||||
onconfirm={onCropConfirm}
|
||||
oncancel={() => (cropFile = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -121,7 +121,18 @@
|
||||
</svelte:head>
|
||||
|
||||
<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 -->
|
||||
<section class="mb-8">
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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">← 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>
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { api } from '$lib/utils/api.js';
|
||||
import { toast } from '$lib/stores/toasts.svelte.js';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -235,12 +236,7 @@
|
||||
{#if board.members.length > 0}
|
||||
<div class="mt-3 flex -space-x-1">
|
||||
{#each board.members.slice(0, 5) as member}
|
||||
<div
|
||||
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>
|
||||
<Avatar name={member.user.name} avatarUrl={member.user.avatarUrl} size="sm" class="border-2 border-white dark:border-gray-900" title={member.user.name} />
|
||||
{/each}
|
||||
{#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">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import CardModal from '$lib/components/CardModal.svelte';
|
||||
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
|
||||
import BoardFilters from '$lib/components/BoardFilters.svelte';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import type { FilterState } from '$lib/components/BoardFilters.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -402,12 +403,7 @@
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">Also here:</span>
|
||||
<div class="flex -space-x-1.5">
|
||||
{#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer}
|
||||
<div
|
||||
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>
|
||||
<Avatar name={viewer.name} avatarUrl={viewer.avatarUrl} size="sm" class="border-2 border-white dark:border-gray-900" title={viewer.name} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,7 +464,8 @@
|
||||
{#each columns as column (column.id)}
|
||||
{@const visibleCards = filterCards(column.cards)}
|
||||
<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 }}
|
||||
>
|
||||
<!-- Column header -->
|
||||
@@ -536,7 +533,8 @@
|
||||
{@const progress = getChecklistProgress(card)}
|
||||
{@const dueUrgency = getDueUrgency(card.dueDate)}
|
||||
<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 }}
|
||||
onclick={() => (selectedCard = card)}
|
||||
>
|
||||
@@ -589,12 +587,7 @@
|
||||
{#if card.assignees && card.assignees.length > 0}
|
||||
<div class="flex -space-x-1 mt-2">
|
||||
{#each card.assignees.slice(0, 3) as assignee}
|
||||
<div
|
||||
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>
|
||||
<Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" class="border border-white" title={assignee.user.name} />
|
||||
{/each}
|
||||
{#if card.assignees.length > 3}
|
||||
<div
|
||||
@@ -681,7 +674,7 @@
|
||||
{#if data.canEdit}
|
||||
<div class="flex-shrink-0 w-72">
|
||||
{#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(); }}>
|
||||
<input
|
||||
bind:value={addingColumnTitle}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
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">
|
||||
{#each members as member (member.id)}
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
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>
|
||||
<Avatar name={member.user.name} avatarUrl={member.user.avatarUrl} size="lg" />
|
||||
<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-xs text-gray-500 dark:text-gray-400 truncate">{member.user.email}</p>
|
||||
|
||||
Reference in New Issue
Block a user