feat: fuzzy ticket search in command palette, improved styling
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user