/* ============================================================================ CommandPalette — ⌘K global search over writing, projects, pages, actions. Keyboard-first, scoped, with recents + monochrome match highlighting. Depends on globals from components.jsx: Icon, POSTS, PROJECTS, Crosshair. ========================================================================== */ const CMDK_RECENT_KEY = "amh-cmdk-recent"; /* --- recents (localStorage) ------------------------------------------------ */ function loadRecents() { try { return JSON.parse(localStorage.getItem(CMDK_RECENT_KEY) || "[]"); } catch (e) { return []; } } function saveRecents(list) { try { localStorage.setItem(CMDK_RECENT_KEY, JSON.stringify(list.slice(0, 6))); } catch (e) {} } /* --- text helpers ---------------------------------------------------------- */ function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function Highlight({ text, query }) { const tokens = (query || "").trim().split(/\s+/).filter(Boolean).map(escRe); if (!tokens.length) return text; const re = new RegExp("(" + tokens.join("|") + ")", "ig"); const parts = String(text).split(re); return parts.map((p, i) => (i % 2 === 1 ? {p} : p)); } /* --- scoring: AND over tokens; title prefix/contains weighted highest ------ */ function scoreItem(item, q) { const tokens = q.split(/\s+/).filter(Boolean); if (!tokens.length) return 0; const title = item.title.toLowerCase(); const hay = item._hay; let s = 0; for (const t of tokens) { if (hay.indexOf(t) === -1) return -1; // every token must appear somewhere const it = title.indexOf(t); if (it === 0) s += 100; else if (it > 0) s += 60 - Math.min(it, 40); else s += 18; } if (title.includes(q)) s += 40; return s; } const SCOPES = [ { key: "all", label: "All" }, { key: "writing", label: "Writing" }, { key: "projects", label: "Projects" }, { key: "pages", label: "Pages" }, { key: "actions", label: "Actions" }, ]; const TYPE_ORDER = ["writing", "projects", "pages", "actions"]; const TYPE_LABEL = { writing: "Writing", projects: "Projects", pages: "Pages", actions: "Actions" }; function CommandPalette({ open, onClose, go, openPost, goWriting, theme, toggleTheme }) { const [query, setQuery] = React.useState(""); const [scope, setScope] = React.useState("all"); const [active, setActive] = React.useState(0); const [recents, setRecents] = React.useState(loadRecents); const [toast, setToast] = React.useState(null); const inputRef = React.useRef(null); const listRef = React.useRef(null); const rowRefs = React.useRef([]); const toastTimer = React.useRef(null); const flash = (msg) => { setToast(msg); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 1500); }; /* ---- build the searchable index (rebuilds when actions' context changes) -- */ const index = React.useMemo(() => { const items = []; POSTS.forEach((p) => items.push({ id: "w-" + p.id, type: "writing", icon: "file-text", title: p.title, sub: `${p.date} · ${p.read} · ${p.tags.map((t) => "#" + t).join(" ")}`, kicker: p.cat, keywords: `${p.desc} ${p.cat} ${p.tags.join(" ")}`, run: () => openPost(p), })); PROJECTS.forEach((p) => items.push({ id: "j-" + p.id, type: "projects", icon: "box", title: p.title, sub: `${p.lang} · ★ ${p.stars} · ${p.year}`, kicker: "Project", keywords: `${p.tagline} ${p.lang} open source`, run: () => go("projects"), })); const PAGES = [ { id: "home", title: "Home", sub: "The front door", icon: "home", kw: "landing front start" }, { id: "writing", title: "Writing", sub: "Essays & notes", icon: "pen-line", kw: "blog articles essays notes archive" }, { id: "projects", title: "Projects", sub: "Things I've built", icon: "box", kw: "portfolio work shipped tools" }, { id: "photos", title: "Photos", sub: "Seen, kept", icon: "image", kw: "gallery photography pictures" }, { id: "about", title: "About", sub: "Who & why", icon: "user", kw: "bio me aaron hampton contact" }, { id: "subscribe", title: "Newsletter", sub: "Subscribe — leave equipped", icon: "mail", kw: "subscribe email join updates" }, ]; PAGES.forEach((pg) => items.push({ id: "pg-" + pg.id, type: "pages", icon: pg.icon, title: pg.title, sub: pg.sub, kicker: "Page", keywords: pg.kw, run: () => go(pg.id), })); // quick actions items.push({ id: "a-theme", type: "actions", icon: theme === "dark" ? "sun" : "moon", title: `Switch to ${theme === "dark" ? "light" : "dark"} theme`, sub: "Toggle the page appearance", kicker: "Toggle", keywords: "theme dark light mode appearance switch", keepOpen: true, run: () => toggleTheme(), }); items.push({ id: "a-subscribe", type: "actions", icon: "mail", title: "Subscribe to the newsletter", sub: "One idea, worked down to a next action", kicker: "Action", keywords: "subscribe newsletter email join signup", run: () => go("subscribe"), }); items.push({ id: "a-copy", type: "actions", icon: "link", title: "Copy link to this page", sub: "Put the current URL on your clipboard", kicker: "Action", keywords: "copy link share url permalink", keepOpen: true, run: () => { const url = window.location.href; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(() => flash("↳ Link copied")).catch(() => flash("↳ Copy failed")); } else { flash("↳ " + url); } }, }); items.push({ id: "a-rss", type: "actions", icon: "rss", title: "Subscribe via RSS", sub: "Plain feed — read it anywhere", kicker: "Action", keywords: "rss feed atom syndication", run: () => { window.open("#", "_blank"); }, }); items.push({ id: "a-github", type: "actions", icon: "github", title: "View source on GitHub", sub: "The engine behind this site", kicker: "Action", keywords: "github source code repository open", run: () => { window.open("#", "_blank"); }, }); ["philosophy", "technology", "history", "politics", "science"].forEach((tag) => items.push({ id: "a-tag-" + tag, type: "actions", icon: "hash", title: `Browse #${tag} writing`, sub: "Filter the writing index by this tag", kicker: "Filter", keywords: `tag filter topic ${tag} writing`, run: () => goWriting(tag), })); items.forEach((it) => { it._hay = (it.title + " " + (it.keywords || "")).toLowerCase(); }); return items; }, [go, openPost, goWriting, theme, toggleTheme]); /* ---- per-scope counts for the chips (respect current query) --------------- */ const counts = React.useMemo(() => { const q = query.trim().toLowerCase(); const c = { all: 0, writing: 0, projects: 0, pages: 0, actions: 0 }; index.forEach((it) => { if (q && scoreItem(it, q) < 0) return; c.all++; c[it.type]++; }); return c; }, [index, query]); /* ---- grouped, ordered results --------------------------------------------- */ const groups = React.useMemo(() => { const q = query.trim().toLowerCase(); let pool = index; if (scope !== "all") pool = pool.filter((it) => it.type === scope); if (!q) { // empty-query "browse" state if (scope === "all") { const g = []; const pages = index.filter((it) => it.type === "pages"); const posts = index.filter((it) => it.type === "writing").slice(0, 4); const acts = index.filter((it) => it.type === "actions").slice(0, 4); g.push({ key: "pages", label: "Jump to", items: pages }); g.push({ key: "writing", label: "Suggested reading", items: posts }); g.push({ key: "actions", label: "Quick actions", items: acts }); return g; } return [{ key: scope, label: TYPE_LABEL[scope], items: pool }]; } const scored = pool .map((it) => ({ it, s: scoreItem(it, q) })) .filter((x) => x.s >= 0) .sort((a, b) => b.s - a.s); const byType = {}; scored.forEach(({ it }) => { (byType[it.type] = byType[it.type] || []).push(it); }); return TYPE_ORDER .filter((t) => byType[t] && byType[t].length) .map((t) => ({ key: t, label: TYPE_LABEL[t], items: byType[t] })); }, [index, query, scope]); const flat = React.useMemo(() => groups.flatMap((g) => g.items), [groups]); /* ---- reset on open + focus ------------------------------------------------ */ React.useEffect(() => { if (open) { setQuery(""); setScope("all"); setActive(0); setRecents(loadRecents()); const t = setTimeout(() => inputRef.current && inputRef.current.focus(), 30); document.body.style.overflow = "hidden"; return () => { clearTimeout(t); document.body.style.overflow = ""; }; } }, [open]); React.useEffect(() => { setActive(0); }, [query, scope]); /* ---- keep active row visible (no scrollIntoView) -------------------------- */ React.useEffect(() => { const el = rowRefs.current[active]; const list = listRef.current; if (!el || !list) return; const top = el.offsetTop; const bottom = top + el.offsetHeight; if (top < list.scrollTop) list.scrollTop = top - 8; else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight + 8; }, [active]); if (!open) return null; const addRecent = (q) => { const v = q.trim(); if (v.length < 2) return; const next = [v, ...recents.filter((r) => r.toLowerCase() !== v.toLowerCase())].slice(0, 6); setRecents(next); saveRecents(next); }; const activate = (item) => { if (!item) return; if (query.trim().length >= 2) addRecent(query); item.run(); if (!item.keepOpen) onClose(); }; const onKeyDown = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => (flat.length ? (i + 1) % flat.length : 0)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => (flat.length ? (i - 1 + flat.length) % flat.length : 0)); } else if (e.key === "Enter") { e.preventDefault(); activate(flat[active]); } else if (e.key === "Escape") { e.preventDefault(); onClose(); } else if (e.key === "Tab") { e.preventDefault(); const dir = e.shiftKey ? -1 : 1; const i = SCOPES.findIndex((s) => s.key === scope); setScope(SCOPES[(i + dir + SCOPES.length) % SCOPES.length].key); } }; const mod = (typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || "")) ? "⌘" : "Ctrl"; // running flat index across groups for active mapping let counter = -1; return (
{ if (e.target === e.currentTarget) onClose(); }}>
setQuery(e.target.value)} onKeyDown={onKeyDown} spellCheck={false} autoComplete="off" />
{SCOPES.map((s) => ( ))}
{!query && recents.length > 0 && (
Recent searches
{recents.map((r) => ( ))}
)} {flat.length === 0 ? (

No matches for “{query}”

Try a topic (#philosophy), a project, or an action like “theme”.

) : ( groups.map((g) => (
{g.label}{g.items.length}
{g.items.map((item) => { counter += 1; const idx = counter; const isActive = idx === active; return (
{ rowRefs.current[idx] = el; }} className={"cmdk-row" + (isActive ? " active" : "")} onMouseEnter={() => setActive(idx)} onClick={() => activate(item)} > {isActive ? Open ↵ : {item.kicker}}
); })}
)) )}
navigate open tab scope esc close
{flat.length} result{flat.length === 1 ? "" : "s"}
{toast &&
{toast}
}
); } Object.assign(window, { CommandPalette });