feat: add global search

This commit is contained in:
haritabh-z01 2025-09-17 23:47:59 +05:30
parent f325def068
commit f4ecb62b93
8 changed files with 310 additions and 76 deletions

View file

@ -4,3 +4,19 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date): string {
const now = new Date();
const diffInDays = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffInDays === 0) return "Today";
if (diffInDays === 1) return "Yesterday";
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}

View file

@ -11,6 +11,9 @@ interface ShortcutData {
}
const api: ElectronAPI = {
// Platform information
platform: process.platform,
sendAudioChunk: (
chunk: Float32Array,
isFinalChunk: boolean = false,

View file

@ -0,0 +1,169 @@
"use client";
import * as React from "react";
import { IconSearch } from "@tabler/icons-react";
import { useNavigate } from "@tanstack/react-router";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { FileTextIcon } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { SETTINGS_NAV_ITEMS } from "../lib/settings-navigation";
// Detect platform for keyboard shortcuts
const isMac = window.electronAPI.platform === "darwin";
export function CommandSearchButton() {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const navigate = useNavigate();
// Client-side filtering for settings
const settingsResults = React.useMemo(() => {
const query = search.toLowerCase().trim();
if (!query) {
return SETTINGS_NAV_ITEMS;
}
return SETTINGS_NAV_ITEMS.filter((page) => {
const searchText = [page.title, page.description].join(" ").toLowerCase();
return searchText.includes(query);
});
}, [search]);
const { data: noteResults = [] } = api.notes.searchNotes.useQuery(
{ query: search },
{
enabled: open,
staleTime: 1000 * 60 * 5,
},
);
const searchResults = React.useMemo(() => {
return [
...settingsResults,
...noteResults.map((n) => ({
...n,
url: `/settings/notes/${n.id}`,
description: formatDate(new Date(n.createdAt)),
type: "note" as const,
icon: n.icon || "file-text",
})),
];
}, [settingsResults, noteResults]);
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const shortcutDisplay = isMac ? "⌘ K" : "Ctrl+K";
const handleSelect = (url: string) => {
setOpen(false);
setSearch("");
navigate({ to: url });
};
return (
<>
<Button
variant="outline"
className="w-full justify-start gap-2 px-2 h-8 text-sm font-normal"
onClick={() => setOpen(true)}
>
<IconSearch className="h-4 w-4" />
<span className="flex-1 text-left">Search...</span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground ml-auto">
{shortcutDisplay}
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder="Search settings and notes..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{(() => {
// Separate results by type
const noteResults = searchResults.filter(
(item) => item.type === "note",
);
const settingsResults = searchResults.filter(
(item) => item.type === "settings",
);
return (
<>
{settingsResults.length > 0 && (
<CommandGroup heading="Settings">
{settingsResults.map((page) => (
<CommandItem
key={page.url}
value={page.title}
onSelect={() => handleSelect(page.url)}
className="cursor-pointer"
>
{typeof page.icon === "string" ? (
<span className="mr-2 text-xl">{page.icon}</span>
) : (
<page.icon className="mr-2 h-4 w-4" />
)}
<div className="flex flex-col">
<span className="font-medium">{page.title}</span>
<span className="text-xs text-muted-foreground">
{page.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{noteResults.length > 0 && (
<CommandGroup heading="Notes">
{noteResults.map((note) => (
<CommandItem
key={note.url}
value={note.title}
onSelect={() => handleSelect(note.url)}
className="cursor-pointer"
>
{note.icon ? (
<FileTextIcon className="mr-2 h-4 w-4" />
) : (
<span className="mr-2 text-xl">{note.icon}</span>
)}
<div className="flex flex-col">
<span className="font-medium">{note.title}</span>
<span className="text-xs text-muted-foreground">
{note.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</>
);
})()}
</CommandList>
</CommandDialog>
</>
);
}

View file

@ -1,16 +1,5 @@
import * as React from "react";
import {
IconSettings,
IconMicrophone,
IconBook,
IconBrain,
IconHistory,
IconInfoCircle,
IconBookFilled,
IconKeyboard,
IconAdjustments,
IconNotes,
} from "@tabler/icons-react";
import { IconBookFilled } from "@tabler/icons-react";
import { NavMain } from "@/components/nav-main";
import { NavSecondary } from "@/components/nav-secondary";
@ -23,6 +12,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { CommandSearchButton } from "./command-search-button";
import { SETTINGS_NAV_ITEMS } from "../lib/settings-navigation";
// Custom Discord icon component
const DiscordIcon = ({ className }: { className?: string }) => (
@ -34,53 +25,11 @@ const DiscordIcon = ({ className }: { className?: string }) => (
);
const data = {
navMain: [
{
title: "Preferences",
url: "/settings/preferences",
icon: IconSettings,
},
{
title: "Dictation",
url: "/settings/dictation",
icon: IconMicrophone,
},
{
title: "Shortcuts",
url: "/settings/shortcuts",
icon: IconKeyboard,
},
{
title: "Notes",
url: "/settings/notes",
icon: IconNotes,
},
{
title: "Vocabulary",
url: "/settings/vocabulary",
icon: IconBook,
},
{
title: "AI Models",
url: "/settings/ai-models",
icon: IconBrain,
},
{
title: "History",
url: "/settings/history",
icon: IconHistory,
},
{
title: "Advanced",
url: "/settings/advanced",
icon: IconAdjustments,
},
{
title: "About",
url: "/settings/about",
icon: IconInfoCircle,
},
],
navMain: SETTINGS_NAV_ITEMS.map(({ title, url, icon }) => ({
title,
url,
icon: typeof icon === "string" ? undefined : icon,
})),
navSecondary: [
{
title: "Docs",
@ -120,6 +69,9 @@ export function SettingsSidebar({
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<CommandSearchButton />
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>

View file

@ -0,0 +1,86 @@
import {
IconSettings,
IconMicrophone,
IconBook,
IconBrain,
IconHistory,
IconInfoCircle,
IconKeyboard,
IconAdjustments,
IconNotes,
type Icon,
} from "@tabler/icons-react";
export interface SettingsNavItem {
title: string;
url: string;
description: string;
icon: Icon | string;
type: "settings";
}
export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
{
title: "Preferences",
url: "/settings/preferences",
description: "Configure general application preferences and behavior",
icon: IconSettings,
type: "settings",
},
{
title: "Dictation",
url: "/settings/dictation",
description: "Configure speech recognition and dictation settings",
icon: IconMicrophone,
type: "settings",
},
{
title: "Shortcuts",
url: "/settings/shortcuts",
description: "Customize keyboard shortcuts and hotkeys",
icon: IconKeyboard,
type: "settings",
},
{
title: "Notes",
url: "/settings/notes",
description: "Manage your notes",
icon: IconNotes,
type: "settings",
},
{
title: "Vocabulary",
url: "/settings/vocabulary",
description: "Manage custom vocabulary and word recognition",
icon: IconBook,
type: "settings",
},
{
title: "AI Models",
url: "/settings/ai-models",
description: "Configure AI models and providers",
icon: IconBrain,
type: "settings",
},
{
title: "History",
url: "/settings/history",
description: "View and manage transcription history",
icon: IconHistory,
type: "settings",
},
{
title: "Advanced",
url: "/settings/advanced",
description: "Advanced configuration options",
icon: IconAdjustments,
type: "settings",
},
{
title: "About",
url: "/settings/about",
description: "About Amical and version information",
icon: IconInfoCircle,
type: "settings",
},
];

View file

@ -1,7 +1,7 @@
"use client";
import { FileText, Calendar } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn, formatDate } from "@/lib/utils";
import { Note } from "../types";
interface RecentNoteCardProps {
@ -9,22 +9,6 @@ interface RecentNoteCardProps {
onNoteClick: (noteId: number) => void;
}
function formatDate(date: Date): string {
const now = new Date();
const diffInDays = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffInDays === 0) return "Today";
if (diffInDays === 1) return "Yesterday";
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function NoteCard({ note, onNoteClick }: RecentNoteCardProps) {
return (
<div

View file

@ -101,4 +101,25 @@ export const notesRouter = createRouter({
}
return { success: true };
}),
// Search notes (for command palette)
searchNotes: procedure
.input(
z.object({
query: z.string().optional().default(""),
limit: z.number().optional().default(10),
}),
)
.query(async ({ input }) => {
const notes = await notesService.listNotes({
search: input.query || "",
limit: input.limit,
});
return notes.map((note) => ({
id: note.id,
title: note.title,
createdAt: note.createdAt,
icon: note.icon || "file-text",
}));
}),
});

View file

@ -5,6 +5,9 @@ declare global {
}
export interface ElectronAPI {
// Platform information
platform: NodeJS.Platform;
// Listeners remain the same (two-way to renderer)
onGlobalShortcut: (
callback: (data: { shortcut: string }) => void,