feat: fuzzy ticket search in command palette, improved styling

This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 22:34:28 +02:00
parent b2423f2821
commit 8175b05b23

View File

@@ -7,7 +7,10 @@ import {
PlusIcon, PlusIcon,
LayoutGridIcon, LayoutGridIcon,
SettingsIcon, SettingsIcon,
MessageSquareIcon,
} from "lucide-react"; } from "lucide-react";
import { getTickets } from "@/lib/api";
import type { Ticket } from "@/lib/types";
interface CommandItem { interface CommandItem {
id: string; id: string;
@@ -28,8 +31,17 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [tickets, setTickets] = useState<Ticket[]>([]);
const commands: CommandItem[] = [ useEffect(() => {
if (open) {
getTickets().then(({ data }) => {
if (data) setTickets(data);
});
}
}, [open]);
const alwaysCommands: CommandItem[] = [
{ {
id: "new-ticket", id: "new-ticket",
label: "New ticket", label: "New ticket",
@@ -40,16 +52,6 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
}, },
category: "Actions", category: "Actions",
}, },
{
id: "all-tickets",
label: "All tickets",
icon: LayoutGridIcon,
action: () => {
onOpenChange(false);
router.push("/");
},
category: "Navigate",
},
{ {
id: "admin", id: "admin",
label: "Go to admin", label: "Go to admin",
@@ -60,12 +62,37 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
}, },
category: "Navigate", category: "Navigate",
}, },
{
id: "all-tickets",
label: "All tickets",
icon: LayoutGridIcon,
action: () => {
onOpenChange(false);
router.push("/");
},
category: "Navigate",
},
]; ];
const filtered = commands.filter((cmd) => const ticketCommands: CommandItem[] = tickets
.filter((t) => t.subject.toLowerCase().includes(query.toLowerCase()))
.map((t) => ({
id: `ticket-${t.id}`,
label: t.subject,
icon: MessageSquareIcon,
action: () => {
onOpenChange(false);
router.push(`/tickets/${t.id}`);
},
category: "Tickets",
}));
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase()) cmd.label.toLowerCase().includes(query.toLowerCase())
); );
const filtered = [...alwaysFiltered, ...ticketCommands];
const grouped = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => { const grouped = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
const cat = cmd.category || "Other"; const cat = cmd.category || "Other";
if (!acc[cat]) acc[cat] = []; if (!acc[cat]) acc[cat] = [];
@@ -114,27 +141,27 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
/> />
<div className="fixed top-[20%] left-1/2 -translate-x-1/2 w-full max-w-md"> <div className="fixed top-[20%] left-1/2 -translate-x-1/2 w-full max-w-md">
<div className="bg-[#191a1b] rounded-xl shadow-2xl border border-[rgba(255,255,255,0.08)] overflow-hidden"> <div className="bg-popover rounded-xl shadow-2xl border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 border-b border-[rgba(255,255,255,0.05)]"> <div className="flex items-center gap-2 px-3 border-b border-border">
<SearchIcon className="w-4 h-4 text-[#8a8f98] flex-shrink-0" /> <SearchIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Type a command or search..." placeholder="Type a command or search..."
className="w-full h-10 bg-transparent text-sm text-[#f7f8f8] placeholder:text-[#8a8f98] outline-none" className="w-full h-10 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/> />
</div> </div>
<div ref={listRef} className="max-h-64 overflow-y-auto p-1"> <div ref={listRef} className="max-h-64 overflow-y-auto p-1">
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-[#8a8f98]"> <div className="px-3 py-6 text-center text-sm text-muted-foreground">
No results found No results found
</div> </div>
)} )}
{Object.entries(grouped).map(([category, items]) => ( {Object.entries(grouped).map(([category, items]) => (
<div key={category}> <div key={category}>
<div className="px-2 py-1.5 text-xs font-medium text-[#8a8f98]"> <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{category} {category}
</div> </div>
{items.map((item) => { {items.map((item) => {
@@ -144,10 +171,10 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
return ( return (
<button <button
key={item.id} key={item.id}
className={`w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-sm text-left transition-colors ${ className={`w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-sm text-left transition-all duration-150 ${
isSelected isSelected
? "bg-[#5e6ad2] text-white" ? "bg-primary text-primary-foreground"
: "text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.05)]" : "text-foreground hover:bg-accent"
}`} }`}
onClick={item.action} onClick={item.action}
onMouseEnter={() => setSelectedIndex(idx)} onMouseEnter={() => setSelectedIndex(idx)}
@@ -160,7 +187,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
</div> </div>
))} ))}
</div> </div>
<div className="border-t border-[rgba(255,255,255,0.05)] px-3 py-2 flex items-center justify-between text-xs text-[#8a8f98]"> <div className="border-t border-border px-3 py-2 flex items-center justify-between text-xs text-muted-foreground">
<span> Navigate</span> <span> Navigate</span>
<span> Select</span> <span> Select</span>
<span>Esc Dismiss</span> <span>Esc Dismiss</span>