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"))
+ }
>