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 };
}