"use client" // The organization's brand settings: a name, two colours and two logos, with a preview of both themes // that updates as a colour is typed. Saving is two kinds of call -- `branding.brand.set` for the text // fields and `branding.logo.set` for each file chosen -- both administrators' acts, both audited by // the kernel. A member who is not an administrator sees the same page, read-only. // // A save is therefore up to five calls, and a failure in the middle leaves the earlier ones applied: // the calls are one transaction each, never one transaction together. An atomic // `branding.brand.update` (fields, clears and uploads in one procedure) is the known improvement and // needs a contract addition; until then a failed save is retried as a whole, which is safe because // every call is idempotent for the same input. import { isHexColor } from "@conusai-kit/brand" import { useDataClient } from "@conusai-kit/ui/data" import { FormLayout, type FormValues, PageSection, TextField, usePageNavigation } from "@conusai-kit/ui/patterns" import { Button, Field, FieldDescription, FieldGroup, FieldLabel, Input, useHydrated } from "@conusai-kit/ui/primitives" import { type ReactElement, useId, useState } from "react" import type { BrandView, LogoVariant } from "../contract/index.js" import { logoUrl, tenantLogo } from "../contract/logo.js" import { BrandPreview, type PreviewLabels } from "./brand-preview.js" export interface BrandFormLabels { readonly displayName: string readonly primary: string readonly accent: string readonly colourHint: string readonly logoLight: string readonly logoDark: string readonly logoHint: string readonly currentLogo: string /** The heading of the list that says which logo each theme shows. */ readonly logoShows: string readonly logoOwn: string /** Contains `{other}`. */ readonly logoOther: string readonly logoApp: string /** Contains `{theme}`. */ readonly clearLogo: string readonly themeLight: string readonly themeDark: string readonly save: string readonly saved: string readonly readOnly: string readonly preview: PreviewLabels } const emptyToNull = (value: string): string | null => (value.trim().length === 0 ? null : value.trim()) function base64Of(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onerror = () => reject(reader.error ?? new Error("the file could not be read")) reader.onload = () => { const url = String(reader.result) resolve(url.slice(url.indexOf(",") + 1)) } reader.readAsDataURL(file) }) } export function BrandForm({ org, brand, canEdit, defaultPrimary, labels, }: { org: string brand: BrandView canEdit: boolean defaultPrimary: string labels: BrandFormLabels }): ReactElement { const ready = useHydrated() const navigation = usePageNavigation() const client = useDataClient() const id = useId() const [primary, setPrimary] = useState(brand.primary ?? "") const [accent, setAccent] = useState(brand.accent ?? "") const [saved, setSaved] = useState(false) const [uploadRevision, setUploadRevision] = useState(0) const [cleared, setCleared] = useState([]) // The logos as they will be once saved: a cleared one is gone, so the fallback it leaves is what the list says. const shown = { logoLight: cleared.includes("light") ? null : brand.logoLight, logoDark: cleared.includes("dark") ? null : brand.logoDark, } const valid = (value: string): boolean => value.trim() === "" || isHexColor(value.trim()) const previewPrimary = isHexColor(primary.trim()) ? primary.trim() : brand.primary const previewAccent = isHexColor(accent.trim()) ? accent.trim() : brand.accent const save = async ({ displayName = "" }: FormValues, form: FormData): Promise => { setSaved(false) await client.call("branding.brand.set", { org, displayName: emptyToNull(displayName), primary: emptyToNull(primary), accent: emptyToNull(accent), }) for (const variant of cleared) await client.call("branding.logo.clear", { org, variant }) for (const variant of ["light", "dark"] as const) { const file = form.get(`logo-${variant}`) if (file instanceof File && file.size > 0) { await client.call("branding.logo.set", { org, variant, contentType: file.type, body: await base64Of(file) }) } } await navigation.refresh() setCleared([]) setUploadRevision((revision) => revision + 1) setSaved(true) } const colourField = (name: string, label: string, value: string, set: (value: string) => void) => ( set(event.currentTarget.value)} disabled={!canEdit} maxLength={7} spellCheck={false} autoComplete="off" aria-invalid={!valid(value)} aria-describedby={`${id}-colour-hint`} /> ) const fields = ( <> {colourField("primary", labels.primary, primary, setPrimary)} {colourField("accent", labels.accent, accent, setAccent)} {labels.colourHint} {(["light", "dark"] as const).map((variant) => { const current = variant === "light" ? brand.logoLight : brand.logoDark const theme = variant === "light" ? labels.themeLight : labels.themeDark return ( {variant === "light" ? labels.logoLight : labels.logoDark} {current && !cleared.includes(variant) ? ( <> {labels.currentLogo} {canEdit ? ( ) : null} ) : null} ) })} {labels.logoHint}
    {(["light", "dark"] as const).map((theme) => { const choice = tenantLogo(shown, theme) const state = choice === null ? labels.logoApp : choice.variant === theme ? labels.logoOwn : labels.logoOther.replace( "{other}", choice.variant === "light" ? labels.themeLight : labels.themeDark, ) return (
  • {theme === "light" ? labels.preview.light : labels.preview.dark}: {state}
  • ) })}
) if (!canEdit) { return ( <>

{labels.readOnly}

{fields} ) } return ( <> {fields}

{saved ? labels.saved : ""}

) }