SPB Git

spb/airiskindex Public

The most methodologically rigorous, fully transparent AI job-exposure index.

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%
2.9 KB · 93 lines tsx
Raw Blame History
1"use client";23//  File:    occupation-search.tsx4//  Path:    apps/web/components/occupation-search.tsx5//  Project: AI Risk Index — airiskindex.io6//  Author:  Simon-Pierre Boucher7//  Contact: contact@spboucher.ai8//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.9//10//  Description: Debounced live occupation search box (client component).111213import Link from "next/link";14import { useEffect, useRef, useState } from "react";1516interface Item {17  code: string;18  title: string;19}2021/** Debounced live search over /api/v1/occupations. */22export function OccupationSearch(): JSX.Element {23  const [query, setQuery] = useState("");24  const [items, setItems] = useState<Item[]>([]);25  const [open, setOpen] = useState(false);26  const boxRef = useRef<HTMLDivElement>(null);2728  useEffect(() => {29    if (query.trim().length < 2) {30      setItems([]);31      return;32    }33    const controller = new AbortController();34    const timer = setTimeout(async () => {35      try {36        const response = await fetch(37          `/api/v1/occupations?q=${encodeURIComponent(query.trim())}&per_page=8`,38          { signal: controller.signal },39        );40        if (response.ok) {41          const data = (await response.json()) as { items: Item[] };42          setItems(data.items);43          setOpen(true);44        }45      } catch {46        /* aborted or offline — keep prior results */47      }48    }, 200);49    return () => {50      controller.abort();51      clearTimeout(timer);52    };53  }, [query]);5455  useEffect(() => {56    const onClick = (event: MouseEvent) => {57      if (!boxRef.current?.contains(event.target as Node)) setOpen(false);58    };59    document.addEventListener("mousedown", onClick);60    return () => document.removeEventListener("mousedown", onClick);61  }, []);6263  return (64    <div ref={boxRef} className="relative max-w-xl">65      <input66        type="search"67        value={query}68        onChange={(event) => setQuery(event.target.value)}69        onFocus={() => items.length > 0 && setOpen(true)}70        placeholder="Search 1,000+ occupations — e.g. paralegal, radiologist, roofer…"71        aria-label="Search occupations"72        className="w-full card px-4 py-3 text-base outline-none placeholder:text-[var(--muted)] focus:border-[var(--seq)]"73      />74      {open && items.length > 0 && (75        <ul className="absolute z-20 mt-2 w-full overflow-hidden card shadow-lg">76          {items.map((item) => (77            <li key={item.code} className="border-b border-[var(--grid)] last:border-b-0">78              <Link79                href={`/occupations/${item.code}`}80                className="flex items-baseline justify-between px-4 py-2.5 hover:bg-[var(--wash)]"81                onClick={() => setOpen(false)}82              >83                <span className="truncate">{item.title}</span>84                <span className="ml-3 shrink-0 text-xs text-[var(--muted)]">{item.code}</span>85              </Link>86            </li>87          ))}88        </ul>89      )}90    </div>91  );92}93