marmar-context / web / context / src / clinician / ui.tsx
ui.tsx
Raw
import type { ComponentProps, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError } from "../api/client";
import { Button, Callout, cn, Icon } from "./kit";
import { Link } from "./router";

/** The green mark only ever appears with the word "Context" (never "MARMAR" beside it). */
export function Brand({ to = "/", className, ...rest }: { to?: string } & Omit<ComponentProps<typeof Link>, "to" | "children">) {
  return (
    <Link
      to={to}
      aria-label="Context home"
      className={cn("group inline-flex items-center gap-3 text-[18px] leading-none font-semibold tracking-[-0.01em] text-ink no-underline", className)}
      {...rest}
    >
      <img src="/assets/logo-mark.svg" alt="" className="block size-6 transition-transform duration-300 group-hover:scale-105 group-hover:-rotate-8" />
      <span>Context</span>
    </Link>
  );
}

/** The public site's field, on its older styles. The signed-in screens use the kit's Field. */
export function Field({ id, label, hint, error, children }: { id: string; label: ReactNode; hint?: ReactNode; error?: string; children: ReactNode }) {
  return (
    <div className={`field ${error ? "has-error" : ""}`}>
      <label htmlFor={id}>{label}</label>
      {children}
      {hint && !error && (
        <small className="hint" id={`${id}-hint`}>
          {hint}
        </small>
      )}
      {error && (
        <small className="field-error" id={`${id}-error`} role="alert">
          {error}
        </small>
      )}
    </div>
  );
}

export const Spinner = ({ label = "Loading", className }: { label?: string; className?: string }) => (
  <span role="status" className={cn("inline-flex items-center gap-2 text-[13px] text-muted", className)}>
    <Icon name="loader-4-line" size={16} className="animate-spin-slow" />
    {label}
  </span>
);

export function ErrorNote({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
  if (!error) return null;
  const message = error instanceof Error ? error.message : String(error);
  return (
    <Callout
      tone="red"
      role="alert"
      action={
        onRetry && (
          <Button size="sm" variant="outline" onClick={onRetry}>
            <Icon name="refresh-line" size={14} /> Try again
          </Button>
        )
      }
    >
      {message}
    </Callout>
  );
}

/** Load something from the API, with loading, error and reload. */
export function useLoad<T>(load: () => Promise<T>, deps: unknown[]) {
  const [state, setState] = useState<{ data: T | null; error: unknown; loading: boolean }>({ data: null, error: null, loading: true });
  const run = useRef(0);
  const reload = useCallback(
    (quiet = false) => {
      const id = ++run.current;
      if (!quiet) setState((s) => ({ ...s, loading: true, error: null }));
      load().then(
        (data) => id === run.current && setState({ data, error: null, loading: false }),
        (error) => id === run.current && setState((s) => ({ ...s, error, loading: false })),
      );
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    deps,
  );
  useEffect(() => {
    reload();
  }, [reload]);
  return { ...state, reload, setData: (data: T) => setState({ data, error: null, loading: false }) };
}

export const errorText = (error: unknown) =>
  error instanceof ApiError || error instanceof Error ? error.message : "Something went wrong. Try again.";

/** A short, polite message that announces itself, then fades: a dark chip low in the middle of the screen. Warn swaps the tick for a caution. */
export function useToast() {
  const [toast, setToast] = useState<{ text: string; warn: boolean } | null>(null);
  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(null), 4000);
    return () => clearTimeout(t);
  }, [toast]);
  const show = useCallback((text: string, warn = false) => setToast({ text, warn }), []);
  const node = (
    <div role="status" className="pointer-events-none fixed inset-x-0 bottom-6 z-[60] flex justify-center px-4">
      {toast && (
        <span className="flex max-w-[520px] items-center gap-2.5 rounded-xl bg-tooltip px-4 py-2.5 text-[13.5px] leading-5 text-white shadow-pop animate-[context-rise_220ms_var(--ease-out-expo)]">
          <Icon name={toast.warn ? "error-warning-fill" : "checkbox-circle-fill"} size={16} className={toast.warn ? "text-amber-icon" : "text-brand"} />
          {toast.text}
        </span>
      )}
    </div>
  );
  return { show, node };
}