From 5b3bf2b05892de7dc6e58d63a3c36d3bebcd2ad1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 15 Feb 2026 19:12:49 +0200 Subject: [PATCH] feat: add command palette with cross-board search and actions --- src/App.tsx | 10 +- src/components/board/BoardView.tsx | 14 + src/components/boards/BoardList.tsx | 13 +- .../command-palette/CommandPalette.tsx | 244 ++++++++++++++++++ src/components/layout/TopBar.tsx | 6 + 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 src/components/command-palette/CommandPalette.tsx diff --git a/src/App.tsx b/src/App.tsx index bd0ae7a..523dd5a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { useAppStore } from "@/stores/app-store"; import { AppShell } from "@/components/layout/AppShell"; import { BoardList } from "@/components/boards/BoardList"; import { BoardView } from "@/components/board/BoardView"; +import { CommandPalette } from "@/components/command-palette/CommandPalette"; export default function App() { const initialized = useAppStore((s) => s.initialized); @@ -24,8 +25,11 @@ export default function App() { } return ( - - {view.type === "board-list" ? : } - + <> + + {view.type === "board-list" ? : } + + {}} /> + ); } diff --git a/src/components/board/BoardView.tsx b/src/components/board/BoardView.tsx index 528b867..bb5437f 100644 --- a/src/components/board/BoardView.tsx +++ b/src/components/board/BoardView.tsx @@ -41,6 +41,20 @@ export function BoardView() { const [newColumnTitle, setNewColumnTitle] = useState(""); const inputRef = useRef(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 const [activeId, setActiveId] = useState(null); const [activeType, setActiveType] = useState<"card" | "column" | null>(null); diff --git a/src/components/boards/BoardList.tsx b/src/components/boards/BoardList.tsx index 5938aa4..be01a8d 100644 --- a/src/components/boards/BoardList.tsx +++ b/src/components/boards/BoardList.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAppStore } from "@/stores/app-store"; @@ -9,6 +9,17 @@ export function BoardList() { const boards = useAppStore((s) => s.boards); 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) { return ( <> diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 0000000..8295431 --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -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( + [] + ); + + 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 ( + + + + No results found. + + {/* Current board cards */} + {board && currentBoardCards.length > 0 && ( + + {currentBoardCards.map((card) => ( + handleSelectCard(card.id, board.id)} + > + + {card.title} + + ))} + + )} + + {/* Cross-board search results */} + {crossBoardResults.length > 0 && ( + <> + + + {crossBoardResults + .filter( + (r) => !board || r.boardId !== board.id + ) + .slice(0, 10) + .map((result) => ( + + handleSelectCard(result.cardId, result.boardId) + } + > + + {result.cardTitle} + + {result.boardTitle} + + + ))} + + + )} + + + + {/* Boards */} + + {boards.map((b) => ( + handleSelectBoard(b.id)} + > + + {b.title} + + {b.columnCount} cols, {b.cardCount} cards + + + ))} + + + + + {/* Actions */} + + + + New Board + + + + Toggle Dark Mode + + + + Settings + + + + + ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index dede9cf..7e1ea67 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -128,6 +128,9 @@ export function TopBar() { variant="ghost" size="icon-sm" className="text-pylon-text-secondary hover:text-pylon-text" + onClick={() => + document.dispatchEvent(new CustomEvent("open-command-palette")) + } > @@ -144,6 +147,9 @@ export function TopBar() { variant="ghost" size="icon-sm" className="text-pylon-text-secondary hover:text-pylon-text" + onClick={() => + document.dispatchEvent(new CustomEvent("open-settings-dialog")) + } >