feat: add command palette with cross-board search and actions

This commit is contained in:
Your Name
2026-02-15 19:12:49 +02:00
parent b527d441e3
commit 5b3bf2b058
5 changed files with 283 additions and 4 deletions

View File

@@ -0,0 +1,244 @@
import { useState, useEffect, useCallback } from "react";
import {
FileText,
LayoutDashboard,
Plus,
Moon,
Settings,
Search,
} from "lucide-react";
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from "@/components/ui/command";
import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store";
import { searchAllBoards, type SearchResult } from "@/lib/storage";
interface CommandPaletteProps {
onOpenSettings: () => void;
}
export function CommandPalette({ onOpenSettings }: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [crossBoardResults, setCrossBoardResults] = useState<SearchResult[]>(
[]
);
const boards = useAppStore((s) => s.boards);
const setView = useAppStore((s) => s.setView);
const setTheme = useAppStore((s) => s.setTheme);
const theme = useAppStore((s) => s.settings.theme);
const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const board = useBoardStore((s) => s.board);
const openBoard = useBoardStore((s) => s.openBoard);
// Listen for Ctrl+K and custom event to open
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
function handleOpenPalette() {
setOpen(true);
}
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("open-command-palette", handleOpenPalette);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("open-command-palette", handleOpenPalette);
};
}, []);
// Cross-board search when query has 2+ characters
useEffect(() => {
if (query.length < 2) {
setCrossBoardResults([]);
return;
}
let cancelled = false;
searchAllBoards(query).then((results) => {
if (!cancelled) {
setCrossBoardResults(results);
}
});
return () => {
cancelled = true;
};
}, [query]);
// Reset state when closing
useEffect(() => {
if (!open) {
setQuery("");
setCrossBoardResults([]);
}
}, [open]);
const handleSelectBoard = useCallback(
async (boardId: string) => {
setOpen(false);
await openBoard(boardId);
setView({ type: "board", boardId });
addRecentBoard(boardId);
},
[openBoard, setView, addRecentBoard]
);
const handleSelectCard = useCallback(
(cardId: string, boardId: string) => {
setOpen(false);
// If the card is from the current board, dispatch a custom event to open it
if (board && board.id === boardId) {
document.dispatchEvent(
new CustomEvent("open-card-detail", { detail: { cardId } })
);
} else {
// Navigate to the board first, then open the card
openBoard(boardId).then(() => {
setView({ type: "board", boardId });
addRecentBoard(boardId);
// Small delay to allow BoardView to mount
setTimeout(() => {
document.dispatchEvent(
new CustomEvent("open-card-detail", { detail: { cardId } })
);
}, 100);
});
}
},
[board, openBoard, setView, addRecentBoard]
);
const handleToggleTheme = useCallback(() => {
setOpen(false);
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
}, [theme, setTheme]);
const handleNewBoard = useCallback(() => {
setOpen(false);
// Go to board list and trigger new board dialog
setView({ type: "board-list" });
setTimeout(() => {
document.dispatchEvent(new CustomEvent("open-new-board-dialog"));
}, 100);
}, [setView]);
const handleOpenSettings = useCallback(() => {
setOpen(false);
onOpenSettings();
}, [onOpenSettings]);
// Current board cards for search
const currentBoardCards = board
? Object.values(board.cards)
: [];
return (
<CommandDialog open={open} onOpenChange={setOpen} showCloseButton={false}>
<CommandInput
placeholder="Search cards, boards, actions..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{/* Current board cards */}
{board && currentBoardCards.length > 0 && (
<CommandGroup heading="Cards">
{currentBoardCards.map((card) => (
<CommandItem
key={card.id}
value={`card-${card.title}`}
onSelect={() => handleSelectCard(card.id, board.id)}
>
<FileText className="size-4" />
<span>{card.title}</span>
</CommandItem>
))}
</CommandGroup>
)}
{/* Cross-board search results */}
{crossBoardResults.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Cross-board Results">
{crossBoardResults
.filter(
(r) => !board || r.boardId !== board.id
)
.slice(0, 10)
.map((result) => (
<CommandItem
key={`${result.boardId}-${result.cardId}`}
value={`cross-${result.cardTitle}-${result.boardTitle}`}
onSelect={() =>
handleSelectCard(result.cardId, result.boardId)
}
>
<Search className="size-4" />
<span>{result.cardTitle}</span>
<span className="ml-auto text-xs text-muted-foreground">
{result.boardTitle}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
<CommandSeparator />
{/* Boards */}
<CommandGroup heading="Boards">
{boards.map((b) => (
<CommandItem
key={b.id}
value={`board-${b.title}`}
onSelect={() => handleSelectBoard(b.id)}
>
<LayoutDashboard className="size-4" />
<span>{b.title}</span>
<span className="ml-auto text-xs text-muted-foreground">
{b.columnCount} cols, {b.cardCount} cards
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
{/* Actions */}
<CommandGroup heading="Actions">
<CommandItem value="new-board" onSelect={handleNewBoard}>
<Plus className="size-4" />
<span>New Board</span>
</CommandItem>
<CommandItem value="toggle-dark-mode" onSelect={handleToggleTheme}>
<Moon className="size-4" />
<span>Toggle Dark Mode</span>
</CommandItem>
<CommandItem value="settings" onSelect={handleOpenSettings}>
<Settings className="size-4" />
<span>Settings</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}