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

@@ -3,6 +3,7 @@ import { useAppStore } from "@/stores/app-store";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { BoardList } from "@/components/boards/BoardList"; import { BoardList } from "@/components/boards/BoardList";
import { BoardView } from "@/components/board/BoardView"; import { BoardView } from "@/components/board/BoardView";
import { CommandPalette } from "@/components/command-palette/CommandPalette";
export default function App() { export default function App() {
const initialized = useAppStore((s) => s.initialized); const initialized = useAppStore((s) => s.initialized);
@@ -24,8 +25,11 @@ export default function App() {
} }
return ( return (
<>
<AppShell> <AppShell>
{view.type === "board-list" ? <BoardList /> : <BoardView />} {view.type === "board-list" ? <BoardList /> : <BoardView />}
</AppShell> </AppShell>
<CommandPalette onOpenSettings={() => {}} />
</>
); );
} }

View File

@@ -41,6 +41,20 @@ export function BoardView() {
const [newColumnTitle, setNewColumnTitle] = useState(""); const [newColumnTitle, setNewColumnTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Listen for custom event to open card detail from command palette
useEffect(() => {
function handleOpenCard(e: Event) {
const detail = (e as CustomEvent<{ cardId: string }>).detail;
if (detail?.cardId) {
setSelectedCardId(detail.cardId);
}
}
document.addEventListener("open-card-detail", handleOpenCard);
return () => {
document.removeEventListener("open-card-detail", handleOpenCard);
};
}, []);
// Drag state // Drag state
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [activeType, setActiveType] = useState<"card" | "column" | null>(null); const [activeType, setActiveType] = useState<"card" | "column" | null>(null);

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
@@ -9,6 +9,17 @@ export function BoardList() {
const boards = useAppStore((s) => s.boards); const boards = useAppStore((s) => s.boards);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
// Listen for custom event to open new board dialog from command palette
useEffect(() => {
function handleOpenDialog() {
setDialogOpen(true);
}
document.addEventListener("open-new-board-dialog", handleOpenDialog);
return () => {
document.removeEventListener("open-new-board-dialog", handleOpenDialog);
};
}, []);
if (boards.length === 0) { if (boards.length === 0) {
return ( return (
<> <>

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>
);
}

View File

@@ -128,6 +128,9 @@ export function TopBar() {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() =>
document.dispatchEvent(new CustomEvent("open-command-palette"))
}
> >
<Search className="size-4" /> <Search className="size-4" />
</Button> </Button>
@@ -144,6 +147,9 @@ export function TopBar() {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() =>
document.dispatchEvent(new CustomEvent("open-settings-dialog"))
}
> >
<Settings className="size-4" /> <Settings className="size-4" />
</Button> </Button>