hasari-api / apps /web /components /ModelSelector.tsx
erdoganpeker's picture
v0.3.0 — multimodal vehicle damage MVP
e327f0d
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Check, ChevronDown, Cpu, Sparkles } from 'lucide-react';
import {
DEFAULT_MODEL_ID,
fetchAvailableModels,
getSelectedModelId,
setSelectedModelId,
subscribeSelectedModel,
type ModelOption,
} from '@/lib/models';
/**
* Header-mounted model picker. Drives the `?model=<id>` param appended to
* every `POST /api/v1/inspect` call (see lib/api.ts).
*
* - Loads the catalog from `GET /api/v1/models`; falls back to a hardcoded
* list when the endpoint is offline (parallel backend work).
* - Selection persists in localStorage.
* - A subtle "demo" badge on the trigger makes this prominent on stage.
*/
export function ModelSelector() {
const t = useTranslations('nav.modelSelector');
const [models, setModels] = useState<ModelOption[]>([]);
const [selectedId, setSelectedId] = useState<string>(() =>
getSelectedModelId(),
);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const rootRef = useRef<HTMLDivElement>(null);
// Initial catalog fetch + react to cross-tab changes.
useEffect(() => {
const ac = new AbortController();
(async () => {
const items = await fetchAvailableModels({ signal: ac.signal });
setModels(items);
setLoading(false);
// If our cached id isn't in the catalog, fall back to the first item
// (preferring `recommended`) and persist the correction.
const current = getSelectedModelId();
if (!items.find((m) => m.id === current)) {
const next =
items.find((m) => m.recommended)?.id ?? items[0]?.id ?? DEFAULT_MODEL_ID;
setSelectedModelId(next);
setSelectedId(next);
}
})();
const unsub = subscribeSelectedModel((id) => setSelectedId(id));
return () => {
ac.abort();
unsub();
};
}, []);
// Close on outside click / Esc.
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const selected = useMemo<ModelOption | null>(
() => models.find((m) => m.id === selectedId) ?? null,
[models, selectedId],
);
function pick(id: string) {
setSelectedModelId(id);
setSelectedId(id);
setOpen(false);
}
const triggerLabel = selected
? selected.kind === 'pretrained'
? t('pretrained')
: t('custom')
: t('label');
const TriggerIcon = selected?.kind === 'pretrained' ? Cpu : Sparkles;
return (
<div className="relative" ref={rootRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={t('label')}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
title={selected?.description ?? t('label')}
>
<TriggerIcon className="h-3.5 w-3.5 text-brand-600" aria-hidden />
<span className="hidden sm:inline text-[11px] uppercase tracking-wider text-slate-400">
{t('label')}:
</span>
<span className="max-w-[120px] truncate">{triggerLabel}</span>
{selected?.recommended && (
<span
className="ml-0.5 hidden rounded-full bg-brand-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-brand-800 sm:inline"
aria-label={t('recommended')}
>
{t('demoBadge')}
</span>
)}
<ChevronDown
className={`h-3.5 w-3.5 text-slate-400 transition-transform ${
open ? 'rotate-180' : ''
}`}
aria-hidden
/>
</button>
{open && (
<div
role="listbox"
aria-label={t('label')}
className="absolute right-0 z-40 mt-1.5 w-72 origin-top-right overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg ring-1 ring-black/5"
>
<div className="border-b border-slate-100 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">
{t('heading')}
</p>
<p className="mt-0.5 text-xs text-slate-500">{t('hint')}</p>
</div>
<ul className="max-h-80 overflow-y-auto py-1">
{loading && models.length === 0 && (
<li className="px-3 py-2 text-xs text-slate-400">
{t('loading')}
</li>
)}
{models.map((m) => {
const active = m.id === selectedId;
const Icon = m.kind === 'pretrained' ? Cpu : Sparkles;
return (
<li key={m.id}>
<button
type="button"
role="option"
aria-selected={active}
onClick={() => pick(m.id)}
className={`flex w-full items-start gap-2.5 px-3 py-2 text-left transition-colors ${
active
? 'bg-brand-50 text-brand-900'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<span
className={`mt-0.5 flex h-7 w-7 flex-none items-center justify-center rounded-lg ${
active
? 'bg-brand-600 text-white'
: 'bg-slate-100 text-slate-500'
}`}
>
<Icon className="h-3.5 w-3.5" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="text-sm font-medium">{m.label}</span>
{m.recommended && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-800">
{t('recommended')}
</span>
)}
</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
{m.description ?? m.id}
</span>
</span>
{active && (
<Check className="mt-1 h-4 w-4 flex-none text-brand-600" aria-hidden />
)}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}