feat: add global search
This commit is contained in:
parent
f325def068
commit
f4ecb62b93
8 changed files with 310 additions and 76 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ interface ShortcutData {
|
|||
}
|
||||
|
||||
const api: ElectronAPI = {
|
||||
// Platform information
|
||||
platform: process.platform,
|
||||
|
||||
sendAudioChunk: (
|
||||
chunk: Float32Array,
|
||||
isFinalChunk: boolean = false,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
86
apps/desktop/src/renderer/main/lib/settings-navigation.ts
Normal file
86
apps/desktop/src/renderer/main/lib/settings-navigation.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}));
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue