Files
hope-events/frontend/src/lib/extractColors.ts
T
joshuaandClaude Sonnet 5 86af7093ac Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks
Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:49:51 +02:00

82 lines
2.9 KiB
TypeScript

/**
* Client-only (canvas + Image) — extracts the dominant, non-background colors
* from a locally-selected logo file, to surface as "suggested" swatches in
* the admin Branding tab. Runs off a local blob URL (URL.createObjectURL),
* never a remote URL, so there's no cross-origin/tainted-canvas concern.
*/
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Could not load image"));
img.src = src;
});
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
/**
* Downscales the image to a small canvas and buckets pixels by a coarse RGB
* quantization (histogram approach — cheap enough at this resolution that a
* real k-means isn't needed). Near-white/near-black/low-saturation pixels are
* discarded since those are almost always logo whitespace/background, not
* the brand color someone would want suggested.
*/
export async function extractDominantColors(imageSrc: string, maxColors = 5): Promise<string[]> {
if (typeof document === "undefined") return [];
let img: HTMLImageElement;
try {
img = await loadImage(imageSrc);
} catch {
return [];
}
const size = 50;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return [];
ctx.drawImage(img, 0, 0, size, size);
let data: Uint8ClampedArray;
try {
data = ctx.getImageData(0, 0, size, size).data;
} catch {
return [];
}
const BIN = 24; // quantization step per channel — coarse enough to merge near-duplicate shades, fine enough to keep distinct hues apart
const buckets = new Map<string, { count: number; r: number; g: number; b: number }>();
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
if (a < 200) continue; // transparent/near-transparent — not a real pixel
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const lightness = (max + min) / 2 / 255;
const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
if (lightness > 0.92 || lightness < 0.08) continue; // near-white / near-black
if (sat < 0.15) continue; // low-saturation gray
const key = `${Math.round(r / BIN)}_${Math.round(g / BIN)}_${Math.round(b / BIN)}`;
const bucket = buckets.get(key);
if (bucket) {
bucket.count++;
bucket.r += r; bucket.g += g; bucket.b += b;
} else {
buckets.set(key, { count: 1, r, g, b });
}
}
return Array.from(buckets.values())
.sort((a, b) => b.count - a.count)
.slice(0, maxColors)
.map(b => rgbToHex(b.r / b.count, b.g / b.count, b.b / b.count));
}