spb/qwhpi Public
QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.
Python 63.9%
TypeScript 25.4%
CSS 5.5%
TeX 3.5%
SQL 0.8%
Makefile 0.5%
Dockerfile 0.5%
1/**2 * =============================================================================3 * QWHPI — Quebec Weekly Housing Price Index4 * Author : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * File : web/components/CommandPalette.tsx7 * Purpose : ⌘K command palette — fuzzy jump to any series or page,8 * full keyboard navigation, zero dependencies.9 * =============================================================================10 */11"use client";1213import { useCallback, useEffect, useMemo, useRef, useState } from "react";14import { useRouter } from "next/navigation";15import { fetchGeographies, type GeographyNode } from "../lib/api";1617interface Item {18 label: string;19 hint: string;20 href: string;21}2223const PAGES: Item[] = [24 { label: "Overview", hint: "page", href: "/" },25 { label: "Explore", hint: "page", href: "/explore" },26 { label: "Compare", hint: "page", href: "/compare" },27 { label: "Map", hint: "page", href: "/map" },28 { label: "Methodology", hint: "page", href: "/methodology" },29 { label: "API docs", hint: "page", href: "/api-docs" },30];31const TYPES = ["all", "unifamilial", "condo", "plex"];3233function norm(s: string): string {34 return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();35}3637export default function CommandPalette() {38 const router = useRouter();39 const [open, setOpen] = useState(false);40 const [q, setQ] = useState("");41 const [sel, setSel] = useState(0);42 const [geos, setGeos] = useState<GeographyNode[]>([]);43 const inputRef = useRef<HTMLInputElement>(null);44 const listRef = useRef<HTMLUListElement>(null);4546 useEffect(() => {47 const onKey = (e: KeyboardEvent) => {48 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {49 e.preventDefault();50 setOpen((v) => !v);51 } else if (e.key === "Escape") {52 setOpen(false);53 }54 };55 window.addEventListener("keydown", onKey);56 return () => window.removeEventListener("keydown", onKey);57 }, []);5859 useEffect(() => {60 if (open) {61 setQ("");62 setSel(0);63 setTimeout(() => inputRef.current?.focus(), 30);64 if (!geos.length)65 fetchGeographies().then((g) => setGeos(g.published_series)).catch(() => {});66 }67 }, [open, geos.length]);6869 const items = useMemo<Item[]>(() => {70 const series: Item[] = geos.flatMap((g) =>71 g.property_types.filter((t) => TYPES.includes(t)).map((t) => ({72 label: `${g.geography_name} · ${t}`,73 hint: g.geography_level,74 href: `/explore?geography=${g.geography_id}&type=${t}`,75 })));76 const all = [...PAGES, ...series];77 if (!q) return all.slice(0, 12);78 const tokens = norm(q).split(/\s+/).filter(Boolean);79 return all80 .filter((i) => {81 const hay = norm(i.label + " " + i.hint);82 return tokens.every((t) => hay.includes(t));83 })84 .slice(0, 12);85 }, [geos, q]);8687 const go = useCallback((item: Item) => {88 setOpen(false);89 router.push(item.href);90 }, [router]);9192 const onInputKey = (e: React.KeyboardEvent) => {93 if (e.key === "ArrowDown") {94 e.preventDefault();95 setSel((s) => Math.min(s + 1, items.length - 1));96 } else if (e.key === "ArrowUp") {97 e.preventDefault();98 setSel((s) => Math.max(s - 1, 0));99 } else if (e.key === "Enter" && items[sel]) {100 go(items[sel]);101 }102 };103104 useEffect(() => {105 listRef.current?.children[sel]?.scrollIntoView({ block: "nearest" });106 }, [sel]);107108 if (!open) return null;109110 return (111 <div className="palette-backdrop" onClick={() => setOpen(false)}112 role="dialog" aria-modal="true" aria-label="Command palette">113 <div className="palette" onClick={(e) => e.stopPropagation()}>114 <input115 ref={inputRef}116 value={q}117 onChange={(e) => { setQ(e.target.value); setSel(0); }}118 onKeyDown={onInputKey}119 placeholder="Jump to a series or page… (e.g. montreal condo)"120 aria-label="Search series and pages"121 className="palette-input"122 />123 <ul ref={listRef} className="palette-list" role="listbox">124 {items.map((it, i) => (125 <li key={it.href + it.label} role="option" aria-selected={i === sel}>126 <button127 className={"palette-item" + (i === sel ? " active" : "")}128 onMouseEnter={() => setSel(i)}129 onClick={() => go(it)}130 >131 <span>{it.label}</span>132 <span className="palette-hint">{it.hint}</span>133 </button>134 </li>135 ))}136 {!items.length && (137 <li className="palette-empty">No match — try a region or city name</li>138 )}139 </ul>140 <div className="palette-foot">141 <span>↑↓ navigate</span><span>↵ open</span><span>esc close</span>142 </div>143 </div>144 </div>145 );146}147