227 lines
8.3 KiB
TypeScript
227 lines
8.3 KiB
TypeScript
"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 { type FormEvent, 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"
|
|
import { useTransport } from "./transport.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
|
|
/** The app's `app.failed` message, with `{detail}` still in it. */
|
|
readonly failed: string
|
|
readonly preview: PreviewLabels
|
|
}
|
|
|
|
const emptyToNull = (value: string): string | null => (value.trim().length === 0 ? null : value.trim())
|
|
|
|
function base64Of(file: File): Promise<string> {
|
|
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 { call, describe } = useTransport()
|
|
const id = useId()
|
|
const [primary, setPrimary] = useState(brand.primary ?? "")
|
|
const [accent, setAccent] = useState(brand.accent ?? "")
|
|
const [state, setState] = useState<{ kind: "idle" | "busy" | "saved" } | { kind: "failed"; detail: string }>({
|
|
kind: "idle",
|
|
})
|
|
const [cleared, setCleared] = useState<readonly LogoVariant[]>([])
|
|
|
|
// 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 (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
event.preventDefault()
|
|
const form = new FormData(event.currentTarget)
|
|
setState({ kind: "busy" })
|
|
try {
|
|
await call("branding.brand.set", {
|
|
org,
|
|
displayName: emptyToNull(String(form.get("displayName") ?? "")),
|
|
primary: emptyToNull(primary),
|
|
accent: emptyToNull(accent),
|
|
})
|
|
for (const variant of cleared) await 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 call("branding.logo.set", { org, variant, contentType: file.type, body: await base64Of(file) })
|
|
}
|
|
}
|
|
setState({ kind: "saved" })
|
|
window.location.reload()
|
|
} catch (error) {
|
|
setState({ kind: "failed", detail: describe(error) })
|
|
}
|
|
}
|
|
|
|
const colourField = (name: string, label: string, value: string, set: (value: string) => void) => (
|
|
<div className="kit-field">
|
|
<label htmlFor={`${id}-${name}`}>{label}</label>
|
|
<input
|
|
id={`${id}-${name}`}
|
|
name={name}
|
|
value={value}
|
|
onChange={(event) => set(event.target.value)}
|
|
disabled={!canEdit}
|
|
maxLength={7}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
aria-invalid={!valid(value)}
|
|
aria-describedby={`${id}-colour-hint`}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<form onSubmit={save} aria-busy={state.kind === "busy"}>
|
|
{canEdit ? null : <p role="note">{labels.readOnly}</p>}
|
|
<div className="kit-field">
|
|
<label htmlFor={`${id}-name`}>{labels.displayName}</label>
|
|
<input
|
|
id={`${id}-name`}
|
|
name="displayName"
|
|
defaultValue={brand.displayName ?? ""}
|
|
disabled={!canEdit}
|
|
maxLength={60}
|
|
/>
|
|
</div>
|
|
{colourField("primary", labels.primary, primary, setPrimary)}
|
|
{colourField("accent", labels.accent, accent, setAccent)}
|
|
<p id={`${id}-colour-hint`}>{labels.colourHint}</p>
|
|
{(["light", "dark"] as const).map((variant) => {
|
|
const current = variant === "light" ? brand.logoLight : brand.logoDark
|
|
const theme = variant === "light" ? labels.themeLight : labels.themeDark
|
|
return (
|
|
<div className="kit-field" key={variant}>
|
|
<label htmlFor={`${id}-logo-${variant}`}>{variant === "light" ? labels.logoLight : labels.logoDark}</label>
|
|
<input
|
|
id={`${id}-logo-${variant}`}
|
|
name={`logo-${variant}`}
|
|
type="file"
|
|
accept="image/png,image/webp,image/svg+xml"
|
|
disabled={!canEdit}
|
|
aria-describedby={`${id}-logo-hint`}
|
|
/>
|
|
{current && !cleared.includes(variant) ? (
|
|
<>
|
|
<img
|
|
src={logoUrl(current, "1x")}
|
|
srcSet={`${logoUrl(current, "1x")} 1x, ${logoUrl(current, "2x")} 2x`}
|
|
alt={labels.currentLogo}
|
|
height={32}
|
|
/>
|
|
{canEdit ? (
|
|
<button type="button" onClick={() => setCleared([...cleared, variant])}>
|
|
{labels.clearLogo.replace("{theme}", theme)}
|
|
</button>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
<p id={`${id}-logo-hint`}>{labels.logoHint}</p>
|
|
<section aria-labelledby={`${id}-logo-shows`}>
|
|
<h2 id={`${id}-logo-shows`}>{labels.logoShows}</h2>
|
|
<ul>
|
|
{(["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 (
|
|
<li key={theme}>
|
|
{theme === "light" ? labels.preview.light : labels.preview.dark}: {state}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</section>
|
|
<BrandPreview
|
|
primary={previewPrimary}
|
|
accent={previewAccent}
|
|
defaultPrimary={defaultPrimary}
|
|
labels={labels.preview}
|
|
/>
|
|
{canEdit ? (
|
|
<button type="submit" disabled={state.kind === "busy" || !valid(primary) || !valid(accent)}>
|
|
{labels.save}
|
|
</button>
|
|
) : null}
|
|
<p role="status">{state.kind === "saved" ? labels.saved : ""}</p>
|
|
{state.kind === "failed" ? (
|
|
<p className="app-error" role="alert">
|
|
{labels.failed.replace("{detail}", state.detail)}
|
|
</p>
|
|
) : null}
|
|
</form>
|
|
)
|
|
}
|