Skip to content

Commit aeec5e3

Browse files
Eeveeclaude
andcommitted
Command palette (Cmd+K): quick navigation across the app
- Cmd+K opens searchable command palette overlay - 7 commands: New Filing, History, Courts, Validate, CoS, Settings, Resources - Arrow key navigation, Enter to select, Esc to close - Filtered search by label and description - Category labels and icon highlights on selection - Keyboard shortcut hints in footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2e63288 commit aeec5e3

2 files changed

Lines changed: 119 additions & 1 deletion

File tree

web/app/(app)/layout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
22
import { auth } from "@clerk/nextjs/server";
33
import { redirect } from "next/navigation";
44
import { ToastProvider } from "@/components/toast";
5+
import { CommandPalette } from "@/components/command-palette";
56

67
export const metadata: Metadata = {
78
title: {
@@ -13,5 +14,5 @@ export const metadata: Metadata = {
1314
export default async function AppLayout({ children }: { children: React.ReactNode }) {
1415
const { userId } = await auth();
1516
if (!userId) redirect("/sign-in");
16-
return <ToastProvider>{children}</ToastProvider>;
17+
return <ToastProvider><CommandPalette />{children}</ToastProvider>;
1718
}

web/components/command-palette.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"use client";
2+
3+
import { useState, useEffect, useRef } from "react";
4+
import { useRouter } from "next/navigation";
5+
6+
interface Command {
7+
id: string;
8+
label: string;
9+
description: string;
10+
action: () => void;
11+
icon: string;
12+
category: string;
13+
}
14+
15+
export function CommandPalette() {
16+
const [open, setOpen] = useState(false);
17+
const [query, setQuery] = useState("");
18+
const [selected, setSelected] = useState(0);
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
const router = useRouter();
21+
22+
const commands: Command[] = [
23+
{ id: "file", label: "New Filing", description: "Drop a PDF to start filing", action: () => router.push("/file"), icon: "M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.338-2.32 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z", category: "Filing" },
24+
{ id: "history", label: "Filing History", description: "View past filings", action: () => router.push("/history"), icon: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z", category: "Filing" },
25+
{ id: "courts", label: "Court Directory", description: "Search 207 federal courts", action: () => router.push("/courts"), icon: "M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21", category: "Tools" },
26+
{ id: "validate", label: "Validate PDF", description: "Check CM/ECF requirements", action: () => router.push("/validate"), icon: "M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z", category: "Tools" },
27+
{ id: "certificate", label: "Certificate of Service", description: "Generate CoS", action: () => router.push("/certificate"), icon: "M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z", category: "Tools" },
28+
{ id: "settings", label: "Settings", description: "PACER credentials, profile", action: () => router.push("/settings"), icon: "M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7 7 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a7 7 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a7 7 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a7 7 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z", category: "Account" },
29+
{ id: "resources", label: "What is CM/ECF?", description: "Learn about federal e-filing", action: () => router.push("/what-is-cmecf"), icon: "M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25", category: "Resources" },
30+
];
31+
32+
const filtered = query
33+
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
34+
: commands;
35+
36+
useEffect(() => {
37+
const handler = (e: KeyboardEvent) => {
38+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
39+
e.preventDefault();
40+
setOpen((o) => !o);
41+
setQuery("");
42+
setSelected(0);
43+
}
44+
if (e.key === "Escape" && open) {
45+
setOpen(false);
46+
}
47+
};
48+
window.addEventListener("keydown", handler);
49+
return () => window.removeEventListener("keydown", handler);
50+
}, [open]);
51+
52+
useEffect(() => {
53+
if (open) inputRef.current?.focus();
54+
}, [open]);
55+
56+
const handleKeyDown = (e: React.KeyboardEvent) => {
57+
if (e.key === "ArrowDown") { e.preventDefault(); setSelected((s) => Math.min(s + 1, filtered.length - 1)); }
58+
if (e.key === "ArrowUp") { e.preventDefault(); setSelected((s) => Math.max(s - 1, 0)); }
59+
if (e.key === "Enter" && filtered[selected]) { filtered[selected].action(); setOpen(false); }
60+
};
61+
62+
if (!open) return null;
63+
64+
return (
65+
<div className="fixed inset-0 z-[150] flex items-start justify-center pt-[20vh]" onClick={() => setOpen(false)}>
66+
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
67+
<div className="relative w-[calc(100%-2rem)] sm:w-[520px] bg-white rounded-2xl shadow-2xl border border-[#e8e5e0] overflow-hidden" onClick={(e) => e.stopPropagation()}>
68+
<div className="p-3 border-b border-[#f0eee9] flex items-center gap-3">
69+
<svg className="w-4 h-4 text-[#8a8a8a] shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
70+
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
71+
</svg>
72+
<input
73+
ref={inputRef}
74+
type="text"
75+
value={query}
76+
onChange={(e) => { setQuery(e.target.value); setSelected(0); }}
77+
onKeyDown={handleKeyDown}
78+
placeholder="Search commands..."
79+
className="flex-1 text-[14px] outline-none bg-transparent text-[#1a1a1a] placeholder-[#c4c4c4]"
80+
/>
81+
<kbd className="text-[10px] px-1.5 py-0.5 bg-[#f5f3ee] text-[#8a8a8a] rounded border border-[#e8e5e0] font-mono">esc</kbd>
82+
</div>
83+
<div className="max-h-[300px] overflow-y-auto py-2">
84+
{filtered.map((cmd, i) => (
85+
<button
86+
key={cmd.id}
87+
onClick={() => { cmd.action(); setOpen(false); }}
88+
onMouseEnter={() => setSelected(i)}
89+
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition ${
90+
i === selected ? "bg-[#f0f4fa]" : "hover:bg-[#fafaf8]"
91+
}`}
92+
>
93+
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${i === selected ? "bg-[#1e3a5f] text-white" : "bg-[#f5f3ee] text-[#8a8a8a]"}`}>
94+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
95+
<path strokeLinecap="round" strokeLinejoin="round" d={cmd.icon} />
96+
</svg>
97+
</div>
98+
<div className="min-w-0 flex-1">
99+
<div className="text-[13px] font-medium text-[#1a1a1a]">{cmd.label}</div>
100+
<div className="text-[11px] text-[#8a8a8a] truncate">{cmd.description}</div>
101+
</div>
102+
<span className="text-[9px] text-[#c4c4c4] font-medium shrink-0">{cmd.category}</span>
103+
</button>
104+
))}
105+
{filtered.length === 0 && (
106+
<div className="px-4 py-6 text-center text-[13px] text-[#8a8a8a]">No matching commands</div>
107+
)}
108+
</div>
109+
<div className="border-t border-[#f0eee9] px-4 py-2 flex items-center gap-4 text-[10px] text-[#c4c4c4]">
110+
<span className="flex items-center gap-1"><kbd className="px-1 py-0.5 bg-[#f5f3ee] rounded border border-[#e8e5e0] font-mono">↑↓</kbd> navigate</span>
111+
<span className="flex items-center gap-1"><kbd className="px-1 py-0.5 bg-[#f5f3ee] rounded border border-[#e8e5e0] font-mono"></kbd> select</span>
112+
<span className="flex items-center gap-1"><kbd className="px-1 py-0.5 bg-[#f5f3ee] rounded border border-[#e8e5e0] font-mono">esc</kbd> close</span>
113+
</div>
114+
</div>
115+
</div>
116+
);
117+
}

0 commit comments

Comments
 (0)