import { designToken } from "@ekklesia/design-tokens"; import { Button, ErrorState, InlineNotice, PageHeader, Panel } from "@ekklesia/ui"; import { type ReactElement, useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, } from "react"; import type { DashboardDestination } from "../dashboard-routes.js"; import { type EkklesiaHostConfig, type HostAccount, type HostAccountPreferences, HostProtocolError, saveHostAccountPreferences, } from "../host-protocol.js"; import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js"; import type { AppearancePreference } from "./appearance-preference.js"; import { dashboardBack } from "./dashboard-back.js"; import { DashboardLink } from "./DashboardLink.js"; import type { HostHistory } from "./host-history.js"; import { ShellIcon } from "./shell-icons.js"; /** * The account area (DESIGN.md §9.2, ADR 0062): who you are signed in as, and your own settings. * * Personal, never church administration — that is Settings (ADR 0061). Nothing here needs a * management permission, and nothing here can affect anyone else. */ const ACCOUNT: DashboardDestination = { kind: "account" }; const APPEARANCE: DashboardDestination = { kind: "account-section", section: "appearance" }; /** * `/account`: the person's own account. Their name and email as they signed up with them, and the * personal settings they can change. There is no profile editing yet, so nothing here pretends to * be editable. */ export function AccountHomePage(props: { readonly account: HostAccount; readonly history: HostHistory; readonly onSignOut: () => void; }): ReactElement { const { account } = props; const hasName = account.name.trim().length > 0; return (
{hasName ? (
Name
{accountDisplayName(account)}
) : undefined}
Email
{account.email}
  • Appearance Light, dark, or matching your device, and how much the Dashboard moves.
); } interface ChoiceOption { readonly value: Value; readonly label: string; readonly description: string; } const APPEARANCE_OPTIONS: readonly ChoiceOption[] = [ { description: "Match your device's light or dark setting.", label: "System", value: "system" }, { description: "Always use the light appearance.", label: "Light", value: "light" }, { description: "Always use the dark appearance.", label: "Dark", value: "dark" }, ]; const MOTION_OPTIONS: readonly ChoiceOption[] = [ { description: "Reduce motion when your device asks for less.", label: "System", value: "system", }, { description: "Always keep animation to a minimum.", label: "Reduce", value: "reduce" }, { description: "Keep the Dashboard's subtle animation, even if your device asks for less.", label: "Allow", value: "allow", }, ]; /** * One preference as a native radio group: a `
` named by its ``, each option a real * `` with its label and description. Arrow keys move between options, as the * browser already does for a radio group, and the chosen option is marked by the radio itself and by * weight, never by colour alone. Each row is a full-width touch target. */ function ChoiceGroup(props: { readonly name: string; readonly legend: string; readonly description: string; readonly options: readonly ChoiceOption[]; readonly value: Value; readonly onChange: (value: Value) => void; }): ReactElement { const descriptionId = useId(); return (
{props.legend}

{props.description}

{props.options.map((option) => { const checked = option.value === props.value; return ( ); })}
); } type SaveStatus = | { readonly kind: "idle" } | { readonly kind: "saving" } | { readonly kind: "saved" } | { readonly kind: "failed" }; /** * `/account/appearance`: Appearance (System, Light, Dark) and Motion (System, Reduce, Allow). * * A choice takes effect **at once**, with no reload and no Save button, and is saved to the account * so it follows the person to every device they sign in on. The account is the source of truth: * what the server answers is what the shell applies, and if saving fails, the choice is put back to * what the account still holds, with a message saying so. Other open tabs of this Dashboard follow * along through this browser's cache. */ export function AppearancePage(props: { readonly account: HostAccount; readonly appearance: AppearancePreference; readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly onSaved: (preferences: HostAccountPreferences) => void; readonly onSessionCheck: () => void; readonly returnTarget?: unknown; }): ReactElement { const { appearance, config, onSaved, onSessionCheck } = props; const subscribe = useCallback( (listener: () => void) => appearance.subscribe(listener), [appearance], ); const read = useCallback(() => appearance.current(), [appearance]); const current = useSyncExternalStore(subscribe, read, read); const [status, setStatus] = useState({ kind: "idle" }); // What the account last confirmed: the value a failed save is put back to. const confirmed = useRef(props.account.preferences); const sequence = useRef(0); const inFlight = useRef(new Set()); useEffect(() => { confirmed.current = props.account.preferences; }, [props.account.preferences]); useEffect(() => { const controllers = inFlight.current; return () => { for (const controller of controllers) { controller.abort(); } }; }, []); const choose = (update: Partial): void => { appearance.apply({ ...appearance.current(), ...update }); sequence.current += 1; const mine = sequence.current; const controller = new AbortController(); inFlight.current.add(controller); setStatus({ kind: "saving" }); void saveHostAccountPreferences(config, update, controller.signal) .then((saved) => { confirmed.current = saved; onSaved(saved); if (mine === sequence.current) { appearance.apply(saved); setStatus({ kind: "saved" }); } }) .catch((error: unknown) => { if (controller.signal.aborted) { return; } if (mine === sequence.current) { appearance.apply(confirmed.current); setStatus({ kind: "failed" }); } if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") { // The session may have ended: the shell checks once, and signs the page out if so. onSessionCheck(); } }) .finally(() => { inFlight.current.delete(controller); }); }; return (
(destination.kind === "account" ? "Back to Account" : "Back"), returnTarget: props.returnTarget, })} description="How the Dashboard looks for you, on every device you sign in on." title="Appearance" />
choose({ appearance: value })} options={APPEARANCE_OPTIONS} value={current.appearance} /> choose({ motion: value })} options={MOTION_OPTIONS} value={current.motion} />
{status.kind === "saved" ? ( Saved to your account. ) : undefined} {status.kind === "failed" ? ( It's back to what your account had. Check your connection and try again. ) : undefined}
); } /** The account could not be loaded: the rest of the Dashboard still works, and Retry asks again. */ export function AccountLoadFailedPage(props: { readonly onRetry: () => void }): ReactElement { return (
Retry} description="The rest of the Dashboard still works. Try again in a moment." title="Your account could not be loaded" />
); }