From c98e1c69f26014850a5ca66351b40b3ede0e18b4 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 9 Sep 2026 01:13:49 +0000 Subject: [PATCH] feat(poweruser): command/shortcut registries, Buzzbee tokens, palette 2.0 - Command registry with 27 commands across 5 categories - Shortcut registry with 18 shortcuts + remapping support - useShortcutManager hook with collision detection - Command history store (last 20 commands) - Command palette 2.0: fuzzy search, >/#/@ prefixes, NLP preview - Interactive shortcut remapping in Settings - Buzzbee design token migration (light + dark) - Compact density with real CSS tokens - Motion duration variables --- apps/web/node_modules | 1 + .../src/components/shell/command-palette.tsx | 691 ++++++++++++++---- apps/web/src/hooks/use-keyboard-shortcuts.ts | 136 +--- apps/web/src/index.css | 154 ++-- apps/web/src/lib/commands/index.ts | 19 + apps/web/src/lib/commands/registry.ts | 315 ++++++++ apps/web/src/lib/shortcuts/registry.ts | 150 ++++ .../src/lib/shortcuts/use-shortcut-manager.ts | 190 +++++ .../lib/stores/use-command-history-store.ts | 27 + .../src/lib/stores/use-create-dialog-store.ts | 16 +- apps/web/src/routes/_app/settings.tsx | 249 ++++++- apps/web/tailwind.config.ts | 2 +- node_modules | 1 + 13 files changed, 1570 insertions(+), 381 deletions(-) create mode 120000 apps/web/node_modules create mode 100644 apps/web/src/lib/commands/index.ts create mode 100644 apps/web/src/lib/commands/registry.ts create mode 100644 apps/web/src/lib/shortcuts/registry.ts create mode 100644 apps/web/src/lib/shortcuts/use-shortcut-manager.ts create mode 100644 apps/web/src/lib/stores/use-command-history-store.ts create mode 120000 node_modules diff --git a/apps/web/node_modules b/apps/web/node_modules new file mode 120000 index 0000000..79c9c91 --- /dev/null +++ b/apps/web/node_modules @@ -0,0 +1 @@ +/home/user/projects/dev/ProjectE/apps/web/node_modules \ No newline at end of file diff --git a/apps/web/src/components/shell/command-palette.tsx b/apps/web/src/components/shell/command-palette.tsx index 14024fa..3ea4d03 100644 --- a/apps/web/src/components/shell/command-palette.tsx +++ b/apps/web/src/components/shell/command-palette.tsx @@ -17,6 +17,9 @@ import { LogOut, Inbox, FileBarChart, + Hash, + ChevronRight, + Clock, type LucideIcon, } from "lucide-react"; import { @@ -28,9 +31,16 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; +import { Badge } from "@/components/ui/badge"; import { useThemeStore } from "@/lib/stores/use-theme-store"; import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; +import { useCommandStore } from "@/lib/commands"; +import { useCommandHistoryStore } from "@/lib/stores/use-command-history-store"; +import { parseTaskInput } from "@/lib/nlp"; +import type { Command } from "@/lib/commands/registry"; + +// ─── Navigation ────────────────────────────────────────────────────────── interface NavItem { label: string; @@ -53,13 +63,8 @@ const navItems: NavItem[] = [ { label: "Settings", href: "/settings", icon: Settings }, ]; -interface QuickAction { - label: string; - icon: LucideIcon; - action: () => void; -} +// ─── Recent pages (for empty state) ───────────────────────────────────── -// Recent items store (last 5 visited pages) const RECENT_KEY = "project-e-recent-pages"; function getRecentPages(): string[] { try { @@ -74,15 +79,54 @@ function addRecentPage(href: string) { localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5))); } +// ─── Recent entities ───────────────────────────────────────────────────── + +interface RecentEntity { + id: string; + title: string; + type: string; + link: string; +} + +function getRecentEntities(): RecentEntity[] { + try { + return JSON.parse(localStorage.getItem("project-e-recent-entities") || "[]"); + } catch { + return []; + } +} + +// ─── Fuzzy match ───────────────────────────────────────────────────────── + +function fuzzyMatch(query: string, text: string): boolean { + const lower = query.toLowerCase(); + const target = text.toLowerCase(); + if (target.includes(lower)) return true; + let qi = 0; + for (let ti = 0; ti < target.length && qi < lower.length; ti++) { + if (target[ti] === lower[qi]) qi++; + } + return qi === lower.length; +} + +// ─── Component ─────────────────────────────────────────────────────────── + export function CommandPalette() { const navigate = useNavigate(); const location = useLocation(); const { mode, setMode } = useThemeStore(); const activeDomainId = useApiDomain(); + const { commands: registryCommands, run } = useCommandStore(); + const { record, getRecent } = useCommandHistoryStore(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); const [searchResults, setSearchResults] = useState< Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }> >([]); + const [agentResults, setAgentResults] = useState< + Array<{ id: string; name: string }> + >([]); const searchTimeoutRef = useRef | null>(null); const [isMobile, setIsMobile] = useState(false); @@ -105,8 +149,9 @@ export function CommandPalette() { if (location.pathname !== "/login") addRecentPage(location.pathname); }, [location.pathname]); - // Quick actions - const quickActions: QuickAction[] = [ + // ─── Quick actions ──────────────────────────────────────────────────── + + const quickActions = [ { label: "New task", icon: ListTodo, @@ -149,8 +194,7 @@ export function CommandPalette() { }, ]; - // Theme actions - const themeActions: QuickAction[] = [ + const themeActions = [ { label: mode === "dark" ? "Switch to light mode" : "Switch to dark mode", icon: mode === "dark" ? Sun : Moon, @@ -158,8 +202,7 @@ export function CommandPalette() { }, ]; - // Settings actions - const settingsActions: QuickAction[] = [ + const settingsActions = [ { label: "Log out", icon: LogOut, @@ -171,78 +214,210 @@ export function CommandPalette() { }, ]; - // Search handler - const handleSearch = useCallback(async (query: string) => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } + // ─── Input parsing ──────────────────────────────────────────────────── - if (!query.trim()) { - setSearchResults([]); + const prefix = query.startsWith(">") || query.startsWith("#") || query.startsWith("@") + ? query[0] as ">" | "#" | "@" + : null; + const rawInput = prefix ? query.slice(1).trim() : query.trim(); + const isEmpty = !query; + const isCommandMode = prefix === ">"; + const isTagMode = prefix === "#"; + const isMentionMode = prefix === "@"; + + // ─── Recent commands (top 5) ────────────────────────────────────────── + + const recentCommandIds = getRecent(5); + const recentCommands = recentCommandIds + .map((id) => registryCommands.find((c) => c.id === id)) + .filter(Boolean) as Command[]; + + // ─── Matched commands (fuzzy on keywords) ───────────────────────────── + + const matchedCommands = rawInput + ? registryCommands.filter((cmd) => { + if (isCommandMode || (!prefix && rawInput)) { + // Match on title, keywords, or id + if (fuzzyMatch(rawInput, cmd.title)) return true; + if (cmd.keywords?.some((kw: string) => fuzzyMatch(rawInput, kw))) return true; + if (fuzzyMatch(rawInput, cmd.id)) return true; + } + return false; + }) + : []; + + // ─── Create task item (NLP preview) ─────────────────────────────────── + + const showCreateTask = + rawInput.length > 0 && + !isCommandMode && + !isTagMode && + !isMentionMode; + + const parsedTask = showCreateTask ? parseTaskInput(rawInput) : null; + + const createTaskAction = useCallback(() => { + if (!parsedTask) return; + record("create-task-inline"); + useCreateDialogStore.getState().openCreate("task", { + title: parsedTask.title, + dueDate: parsedTask.dueDate || undefined, + priority: parsedTask.priority || undefined, + tags: parsedTask.tags, + }); + navigate({ to: "/tasks" }); + }, [parsedTask, record, navigate]); + + // ─── Debounced API search ───────────────────────────────────────────── + + const handleSearch = useCallback( + (value: string) => { + setQuery(value); + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + const input = value.trim(); + if (!input || isCommandMode || isMentionMode) { + setSearchResults([]); + return; + } + + // Tag mode: just search tags from the API + if (isTagMode) { + const tagQuery = rawInput; + if (!tagQuery) { + setSearchResults([]); + return; + } + searchTimeoutRef.current = setTimeout(async () => { + try { + const res = await fetch( + `/api/tags?q=${encodeURIComponent(tagQuery)}&limit=5` + + (activeDomainId ? "&domain=" + activeDomainId : "") + ); + if (res.ok) { + const data = await res.json(); + setSearchResults([ + { + type: "Tags", + items: (data.items || []).map((t: { id: string; name: string }) => ({ + id: t.id, + title: t.name, + })), + }, + ]); + } + } catch { + // Ignore + } + }, 300); + return; + } + + // Regular search (no prefix or bare phrase) + if (!prefix || prefix === "#") { + searchTimeoutRef.current = setTimeout(async () => { + try { + const res = await fetch( + `/api/search?q=${encodeURIComponent(input)}&limit=5` + + (activeDomainId ? "&domain=" + activeDomainId : "") + ); + if (res.ok) { + const data = await res.json(); + const flat: Array<{ + type: string; + id: string; + title: string; + link?: string; + }> = data.results || []; + const grouped: Record< + string, + Array<{ id: string; title: string; link?: string }> + > = {}; + for (const r of flat) { + const key = + r.type.charAt(0).toUpperCase() + r.type.slice(1) + "s"; + (grouped[key] = grouped[key] || []).push({ + id: r.id, + title: r.title, + link: r.link, + }); + } + setSearchResults( + Object.entries(grouped).map(([type, items]) => ({ type, items })) + ); + } + } catch { + // Ignore search errors + } + }, 300); + } + }, + [activeDomainId, isCommandMode, isMentionMode, isTagMode, prefix, rawInput] + ); + + // ─── Agent mention search ───────────────────────────────────────────── + + useEffect(() => { + if (!isMentionMode || !rawInput) { + setAgentResults([]); return; } + const controller = new AbortController(); + fetch( + `/api/agents?q=${encodeURIComponent(rawInput)}` + + (activeDomainId ? "&domain=" + activeDomainId : ""), + { signal: controller.signal } + ) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.items) setAgentResults(data.items); + }) + .catch(() => {}); + return () => controller.abort(); + }, [isMentionMode, rawInput, activeDomainId]); - // Check for @mention - if (query.startsWith("@")) { - const mentionQuery = query.slice(1).trim(); - if (mentionQuery) { - try { - const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}` + (activeDomainId ? "&domain=" + activeDomainId : "")); - if (res.ok) { - const data = await res.json(); - setSearchResults([ - { - type: "Agents", - items: (data.items || []).map((a: { id: string; name: string }) => ({ - id: a.id, - title: a.name, - })), - }, - ]); - } - } catch { - // Ignore - } + // ─── Execute command ────────────────────────────────────────────────── + + const executeCommand = useCallback( + (commandId: string) => { + record(commandId); + const cmd = registryCommands.find((c) => c.id === commandId); + if (cmd) { + setOpen(false); + cmd.run({ navigate }); } - return; - } + }, + [registryCommands, record, navigate] + ); - // Debounced API search - searchTimeoutRef.current = setTimeout(async () => { - try { - const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5` + (activeDomainId ? "&domain=" + activeDomainId : "")); - if (res.ok) { - const data = await res.json(); - // The API returns a flat list of SearchResult objects; group them by - // entity type for the grouped render below. - const flat: Array<{ type: string; id: string; title: string; link?: string }> = data.results || []; - const grouped: Record> = {}; - for (const r of flat) { - const key = r.type.charAt(0).toUpperCase() + r.type.slice(1) + "s"; - (grouped[key] = grouped[key] || []).push({ id: r.id, title: r.title, link: r.link }); - } - setSearchResults(Object.entries(grouped).map(([type, items]) => ({ type, items }))); - } - } catch { - // Ignore search errors - } - }, 300); - }, [activeDomainId]); - - const runCommand = useCallback( - (command: () => void) => { + const runQuickAction = useCallback( + (action: () => void) => { setOpen(false); - command(); + action(); }, [] ); - // Recent pages + const runNav = useCallback( + (href: string) => { + setOpen(false); + navigate({ to: href }); + }, + [navigate] + ); + + // ─── Recent pages (for empty state) ─────────────────────────────────── + const recentPages = getRecentPages(); const recentNavItems = recentPages .map((href) => navItems.find((item) => item.href === href)) .filter(Boolean) as NavItem[]; + // ─── Render ─────────────────────────────────────────────────────────── + return ( - No results found. + + {isCommandMode + ? "No commands found." + : isTagMode + ? "No tags found." + : isMentionMode + ? "No agents found." + : "No results found."} + - {/* Recent items */} - {recentNavItems.length > 0 && ( - - {recentNavItems.map((item) => ( - runCommand(() => navigate({ to: item.href }))} - > - - {item.label} - - ))} - - )} - - {/* Navigation */} - - {navItems.map((item) => ( - runCommand(() => navigate({ to: item.href }))} - > - - {item.label} - - ))} - - - {/* Quick Actions */} - - {quickActions.map((action) => ( - runCommand(action.action)} - > - - {action.label} - - ))} - - - {/* Theme */} - - {themeActions.map((action) => ( - runCommand(action.action)} - > - - {action.label} - - ))} - - - {/* Settings */} - - {settingsActions.map((action) => ( - runCommand(action.action)} - > - - {action.label} - - ))} - - - {/* Search Results */} - {searchResults.length > 0 && ( + {/* ─── Empty state (no input) ────────────────────────────────── */} + {isEmpty && ( <> - - {searchResults.map((group) => ( - - {group.items.map((item) => ( + {/* Recent pages */} + {recentNavItems.length > 0 && ( + + {recentNavItems.map((item) => ( { - // The search API returns singular types ("task", "note", ...) - // and each result carries a ready-made detail link (e.g. - // "/tasks/{id}"). Domains have no detail route, so land on the - // dashboard (the domain-scoped home). Agent mentions have no - // detail page either, so just dismiss the palette. - if (group.type === "Agents") { - runCommand(() => {}); - return; - } - const link = group.type === "Domains" ? "/" : item.link!; - runCommand(() => navigate({ to: link })); - }} + key={item.href} + onSelect={() => runNav(item.href)} > - - {item.title} + + {item.label} ))} - ))} + )} + + {/* Recent entities */} + {getRecentEntities().length > 0 && ( + + {getRecentEntities().slice(0, 3).map((entity) => ( + runNav(entity.link)} + > + + {entity.title} + + {entity.type} + + + ))} + + )} + + {/* Recent commands */} + {recentCommands.length > 0 && ( + + {recentCommands.map((cmd) => ( + executeCommand(cmd.id)} + > + + {cmd.title} + + ))} + + )} + + + + {/* Navigation */} + + {navItems.map((item) => ( + runNav(item.href)} + > + + {item.label} + + ))} + + + {/* Quick Actions */} + + {quickActions.map((action) => ( + runQuickAction(action.action)} + > + + {action.label} + + ))} + + + {/* Theme */} + + {themeActions.map((action) => ( + runQuickAction(action.action)} + > + + {action.label} + + ))} + + + {/* Settings */} + + {settingsActions.map((action) => ( + runQuickAction(action.action)} + > + + {action.label} + + ))} + )} - {/* Footer hint */} + {/* ─── Command mode (">" prefix) ────────────────────────────── */} + {isCommandMode && ( + + {matchedCommands.length > 0 ? ( + matchedCommands.map((cmd) => ( + executeCommand(cmd.id)} + > + {cmd.icon && } + {!cmd.icon && } + {cmd.title} + {cmd.keywords && cmd.keywords.length > 0 && ( + + {cmd.keywords[0]} + + )} + + )) + ) : ( + No commands match. + )} + + )} + + {/* ─── Tag mode ("#" prefix) ────────────────────────────────── */} + {isTagMode && ( + <> + {searchResults.length > 0 ? ( + searchResults.map((group) => ( + + {group.items.map((item) => ( + runNav(item.link || "/")} + > + + {item.title} + + ))} + + )) + ) : rawInput ? ( + No tags found. + ) : ( + + Type to search tags... + + )} + + )} + + {/* ─── Agent mention ("@" prefix) ───────────────────────────── */} + {isMentionMode && ( + + {agentResults.length > 0 ? ( + agentResults.map((agent) => ( + { + record("mention-agent"); + setOpen(false); + }} + > + + {agent.name} + + )) + ) : rawInput ? ( + No agents found. + ) : ( + Type to search agents... + )} + + )} + + {/* ─── Typing: no prefix — commands + search + create task ──── */} + {!isEmpty && !prefix && ( + <> + {/* Create task with NLP preview */} + {showCreateTask && parsedTask && ( + + + + Create task: '{parsedTask.title}' +
+ {parsedTask.dueDate && ( + + {new Date(parsedTask.dueDate).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })} + + )} + {parsedTask.priority && ( + + {parsedTask.priority} + + )} + {parsedTask.tags.length > 0 && ( + + {parsedTask.tags[0]} + {parsedTask.tags.length > 1 && ` +${parsedTask.tags.length - 1}`} + + )} +
+
+
+ )} + + {/* Matched commands */} + {matchedCommands.length > 0 && ( + + {matchedCommands.map((cmd) => ( + executeCommand(cmd.id)} + > + {cmd.icon && } + {!cmd.icon && } + {cmd.title} + + ))} + + )} + + {/* Search results */} + {searchResults.length > 0 && ( + <> + + {searchResults.map((group) => ( + + {group.items.map((item) => ( + { + if (group.type === "Agents") { + runQuickAction(() => {}); + return; + } + const link = + group.type === "Domains" ? "/" : item.link!; + runNav(link); + }} + > + + {item.title} + + ))} + + ))} + + )} + + )} + + {/* ─── Footer hint ──────────────────────────────────────────── */}
↑↓ navigate @@ -367,6 +731,11 @@ export function CommandPalette() { esc close + + > commands{" "} + # tags{" "} + @ agents +
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts index b8637dc..3cbc0d7 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -1,131 +1,17 @@ -import { useEffect, useRef } from "react"; -import { useNavigate } from "@tanstack/react-router"; import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store"; -import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; -import { install, uninstall } from "@github/hotkey"; +import { useShortcutManager } from "@/lib/shortcuts/use-shortcut-manager"; +/** + * Thin wrapper that installs all keyboard shortcuts from the central registry + * via {@link useShortcutManager}. The shortcut manager always installs hotkeys + * (unconditionally, to satisfy React's rules-of-hooks), but the underlying + * handler checks the `enabled` flag from the store before dispatching. + */ export function useKeyboardShortcuts() { - const navigate = useNavigate(); const { enabled } = useKeyboardShortcutsStore(); - const containerRef = useRef(null); - useEffect(() => { - if (!enabled) return; - - // Create a hidden container for @github/hotkey installs - const container = document.createElement("div"); - container.setAttribute("aria-hidden", "true"); - container.style.display = "none"; - document.body.appendChild(container); - containerRef.current = container; - - // Helper: install a hotkey on a synthetic element - const addHotkey = (hotkey: string, handler: () => void) => { - const el = document.createElement("span"); - el.setAttribute("data-hotkey", hotkey); - el.addEventListener("hotkey-fire", (e: Event) => { - e.preventDefault(); - handler(); - }); - container.appendChild(el); - install(el, hotkey); - return el; - }; - - // g+letter navigation sequences - const navMap: Record = { - "g i": "/inbox", - "g d": "/", - "g t": "/tasks", - "g h": "/habits", - "g p": "/projects", - "g n": "/notes", - "g c": "/calendar", - "g g": "/graph", - "g r": "/reports", - "g s": "/settings", - }; - - for (const [seq, path] of Object.entries(navMap)) { - addHotkey(seq, () => { - navigate({ to: path }); - }); - } - - // n+letter new-entity sequences — navigate AND open the create dialog on - // the target page (the page's effect consumes the store request). - const newMap: Record = { - "n t": { path: "/tasks", type: "task" }, - "n h": { path: "/habits", type: "habit" }, - "n p": { path: "/projects", type: "project" }, - "n n": { path: "/notes", type: "note" }, - }; - - for (const [seq, { path, type }] of Object.entries(newMap)) { - addHotkey(seq, () => { - useCreateDialogStore.getState().openCreate(type); - navigate({ to: path }); - }); - } - - // Global keydown handler for single-key shortcuts and Cmd+K - const handleKeyDown = (e: KeyboardEvent) => { - // Never override native behavior inside controls or modal UI - const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" || - target.isContentEditable || - target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]') - ) { - return; - } - - // Cmd+K / Ctrl+K — open command palette - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - document.dispatchEvent(new CustomEvent("open-command-palette")); - return; - } - - // Cmd+N / Ctrl+N — new task - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "n") { - e.preventDefault(); - useCreateDialogStore.getState().openCreate("task"); - navigate({ to: "/tasks" }); - return; - } - - // Single-key shortcuts (no modifiers) - if (e.metaKey || e.ctrlKey || e.altKey) return; - - switch (e.key) { - case "?": - e.preventDefault(); - document.dispatchEvent(new CustomEvent("open-shortcuts-help")); - break; - case "/": - e.preventDefault(); - document.dispatchEvent(new CustomEvent("open-command-palette")); - break; - case "c": - e.preventDefault(); - document.dispatchEvent(new CustomEvent("open-command-palette")); - break; - } - }; - - document.addEventListener("keydown", handleKeyDown); - - // Cleanup - return () => { - document.removeEventListener("keydown", handleKeyDown); - for (const child of container.children) { - uninstall(child as HTMLElement); - } - document.body.removeChild(container); - containerRef.current = null; - }; - }, [navigate, enabled]); + // Always call the hook (rules of hooks). The manager's effect installs + // hotkeys unconditionally — the `enabled` guard lives in the dispatch + // path so toggling the flag disables shortcuts without reinstalling. + useShortcutManager(enabled); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fcaccec..d0a14d4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -7,79 +7,101 @@ /* Typography */ --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, - Consolas, "DejaVu Sans Mono", monospace; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "Cascadia Code", "Source Code Pro", + Menlo, Consolas, "DejaVu Sans Mono", monospace; - /* Density — compact is the new default */ - --density-scale: 0.85; + /* Control heights & sizing */ + --control-height: 40px; + --control-height-sm: 32px; + --control-height-lg: 48px; + --size: 16px; + --size-sm: 12px; + --size-md: 20px; + --size-lg: 24px; + --size-xl: 32px; + --size-xxl: 48px; - /* Light mode — clean, near-white with subtle warmth */ - --background: 0 0% 99%; - --foreground: 222 47% 5%; - --card: 0 0% 100%; - --card-foreground: 222 47% 5%; + /* Motion */ + --duration-fast: 0.1s; + --duration-normal: 0.2s; + --duration-slow: 0.3s; + --ease-default: cubic-bezier(0.645, 0.045, 0.355, 1); + + /* Light mode — Buzzbee palette */ + --background: 0 0% 100%; + --foreground: 0 0% 6.7%; + --card: 220 13% 97%; + --card-foreground: 0 0% 6.7%; --popover: 0 0% 100%; - --popover-foreground: 222 47% 5%; - --primary: 222 47% 11%; - --primary-foreground: 0 0% 98%; - --secondary: 220 14% 96%; - --secondary-foreground: 222 47% 11%; - --muted: 220 14% 96%; - --muted-foreground: 220 9% 46%; - --accent: 220 14% 96%; - --accent-foreground: 222 47% 11%; + --popover-foreground: 0 0% 6.7%; + --primary: 217 91% 60%; + --primary-foreground: 0 0% 100%; + --secondary: 210 40% 96%; + --secondary-foreground: 0 0% 18%; + --muted: 210 40% 96%; + --muted-foreground: 220 9% 45%; + --accent: 210 100% 95%; + --accent-foreground: 222 100% 27%; --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 98%; - --border: 220 13% 91%; - --input: 220 13% 91%; - --ring: 217 91% 60%; - --radius: 0.375rem; + --destructive-foreground: 0 0% 100%; + --border: 214 32% 88%; + --input: 214 32% 88%; + --ring: 210 100% 75%; + --radius: 0.5rem; /* Interactive accent — pinned vibrant blue */ --accent-hsl: 217 91% 60%; /* Shell surfaces */ --sidebar-bg: 0 0% 100%; - --sidebar-border: 220 13% 91%; - --topbar-bg: 0 0% 99%; + --sidebar-border: 214 32% 88%; + --topbar-bg: 0 0% 100%; } /* Density modes */ .density-compact { - --density-scale: 0.85; + --control-height: 28px; + --control-height-sm: 21px; + --control-height-lg: 35px; + --size: 12px; + --size-sm: 8px; + --size-md: 16px; + --size-lg: 16px; + --size-xl: 24px; + --size-xxl: 32px; } .density-spacious { --density-scale: 1.15; } - /* Dark mode — deep navy with elevated surfaces */ + /* Dark mode — Buzzbee dark palette */ .dark { - --background: 222 47% 5%; - --foreground: 210 40% 98%; - --card: 222 40% 8%; - --card-foreground: 210 40% 98%; - --popover: 222 40% 8%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222 47% 11%; - --secondary: 217 33% 14%; - --secondary-foreground: 210 40% 98%; - --muted: 217 33% 14%; - --muted-foreground: 215 20% 65%; - --accent: 217 33% 14%; - --accent-foreground: 210 40% 98%; - --destructive: 0 63% 31%; - --destructive-foreground: 210 40% 98%; - --border: 217 33% 17%; - --input: 217 33% 17%; - --ring: 217 91% 60%; - --accent-hsl: 217 91% 60%; + --background: 0 0% 8%; + --foreground: 0 0% 86%; + --card: 0 0% 11%; + --card-foreground: 0 0% 86%; + --popover: 0 0% 15%; + --popover-foreground: 0 0% 86%; + --primary: 212 70% 55%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 19%; + --secondary-foreground: 0 0% 86%; + --muted: 0 0% 19%; + --muted-foreground: 0 0% 49%; + --accent: 216 47% 13%; + --accent-foreground: 205 80% 85%; + --destructive: 0 60% 63%; + --destructive-foreground: 0 0% 100%; + --border: 0 0% 24%; + --input: 0 0% 24%; + --ring: 212 60% 50%; + --accent-hsl: 212 70% 55%; /* Shell surfaces — slightly elevated from background */ - --sidebar-bg: 222 42% 7%; - --sidebar-border: 217 33% 17%; - --topbar-bg: 222 47% 5%; + --sidebar-bg: 0 0% 11%; + --sidebar-border: 0 0% 24%; + --topbar-bg: 0 0% 8%; } } @@ -103,35 +125,3 @@ transition-duration: 0.001s !important; animation-iteration-count: 1 !important; } - -@layer utilities { - /* Density: multiply vertical rhythm inside
*/ - .density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]), - .density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) { - margin-top: calc(0.25rem * var(--density-scale)); - } - .density-compact main .space-y-2 > :not([hidden]) ~ :not([hidden]), - .density-spacious main .space-y-2 > :not([hidden]) ~ :not([hidden]) { - margin-top: calc(0.5rem * var(--density-scale)); - } - .density-compact main .space-y-3 > :not([hidden]) ~ :not([hidden]), - .density-spacious main .space-y-3 > :not([hidden]) ~ :not([hidden]) { - margin-top: calc(0.75rem * var(--density-scale)); - } - .density-compact main .space-y-4 > :not([hidden]) ~ :not([hidden]), - .density-spacious main .space-y-4 > :not([hidden]) ~ :not([hidden]) { - margin-top: calc(1rem * var(--density-scale)); - } - .density-compact main .space-y-5 > :not([hidden]) ~ :not([hidden]), - .density-spacious main .space-y-5 > :not([hidden]) ~ :not([hidden]) { - margin-top: calc(1.25rem * var(--density-scale)); - } - .density-compact main .space-y-6 > :not([hidden]) ~ :not([hidden]), - .density-spacious main .space-y-6 > :not([hidden]) ~ :not([hidden]) { - margin-top: calc(1.5rem * var(--density-scale)); - } - .density-compact main .space-y-8 > :not([hidden]) ~ :not([hidden]), - .density-spacious main .space-y-8 > :not([hidden]) ~ :not([hidden]) { - margin-top: calc(2rem * var(--density-scale)); - } -} diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts new file mode 100644 index 0000000..9c5f96a --- /dev/null +++ b/apps/web/src/lib/commands/index.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; +import { commands, type Command, type CommandContext } from "./registry"; + +interface CommandState { + commands: Command[]; + run: (id: string, ctx: CommandContext) => void; +} + +export const useCommandStore = create()((set) => ({ + commands, + run: (id, ctx) => { + const cmd = commands.find((c) => c.id === id); + if (cmd) cmd.run(ctx); + }, +})); + +export function useCommands() { + return useCommandStore(); +} diff --git a/apps/web/src/lib/commands/registry.ts b/apps/web/src/lib/commands/registry.ts new file mode 100644 index 0000000..ecd80b1 --- /dev/null +++ b/apps/web/src/lib/commands/registry.ts @@ -0,0 +1,315 @@ +import type { LucideIcon } from "lucide-react"; +import { + LayoutDashboard, + ListTodo, + Flame, + FolderKanban, + NotebookPen, + CalendarDays, + Share2, + Settings, + Bot, + FileBarChart, + Inbox, + Plus, + Sun, + Moon, + Key, + Globe, + Tag, + List, + Webhook, + Upload, +} from "lucide-react"; +import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; +import { useThemeStore } from "@/lib/stores/use-theme-store"; + +export interface Command { + id: string; + title: string; + keywords: string[]; + icon: LucideIcon; + category: "navigation" | "create" | "theme" | "settings" | "help"; + run: (ctx: CommandContext) => void; + shortcut?: string; +} + +export interface CommandContext { + navigate: (opts: { to: string }) => void; +} + +function navigateTo(path: string) { + return (ctx: CommandContext) => ctx.navigate({ to: path }); +} + +function navigateToSettingsTab(tab: string) { + return (ctx: CommandContext) => { + ctx.navigate({ to: "/settings" }); + // Dispatch after navigation so the settings page is mounted + requestAnimationFrame(() => { + document.dispatchEvent(new CustomEvent("open-settings-tab", { detail: tab })); + }); + }; +} + +function openCreate(type: "task" | "habit" | "project" | "note" | "event", path: string) { + return (ctx: CommandContext) => { + useCreateDialogStore.getState().openCreate(type); + ctx.navigate({ to: path }); + }; +} + +function toggleTheme() { + return (_ctx: CommandContext) => { + const { mode, setMode } = useThemeStore.getState(); + setMode(mode === "dark" ? "light" : "dark"); + }; +} + +function dispatchEvent(eventName: string) { + return (_ctx: CommandContext) => { + document.dispatchEvent(new CustomEvent(eventName)); + }; +} + +export const commands: Command[] = [ + // ── Navigation ───────────────────────────────────────────────────────── + { + id: "nav-inbox", + title: "Go to Inbox", + keywords: ["inbox", "messages", "notifications"], + icon: Inbox, + category: "navigation", + run: navigateTo("/inbox"), + shortcut: "g+i", + }, + { + id: "nav-dashboard", + title: "Go to Dashboard", + keywords: ["dashboard", "home", "overview"], + icon: LayoutDashboard, + category: "navigation", + run: navigateTo("/"), + shortcut: "g+d", + }, + { + id: "nav-tasks", + title: "Go to Tasks", + keywords: ["tasks", "todo", "list"], + icon: ListTodo, + category: "navigation", + run: navigateTo("/tasks"), + shortcut: "g+t", + }, + { + id: "nav-habits", + title: "Go to Habits", + keywords: ["habits", "streaks", "tracker"], + icon: Flame, + category: "navigation", + run: navigateTo("/habits"), + shortcut: "g+h", + }, + { + id: "nav-projects", + title: "Go to Projects", + keywords: ["projects", "workspaces"], + icon: FolderKanban, + category: "navigation", + run: navigateTo("/projects"), + shortcut: "g+p", + }, + { + id: "nav-notes", + title: "Go to Notes", + keywords: ["notes", "documents", "wiki"], + icon: NotebookPen, + category: "navigation", + run: navigateTo("/notes"), + shortcut: "g+n", + }, + { + id: "nav-calendar", + title: "Go to Calendar", + keywords: ["calendar", "events", "schedule"], + icon: CalendarDays, + category: "navigation", + run: navigateTo("/calendar"), + shortcut: "g+c", + }, + { + id: "nav-graph", + title: "Go to Graph", + keywords: ["graph", "knowledge", "network", "connections"], + icon: Share2, + category: "navigation", + run: navigateTo("/graph"), + shortcut: "g+g", + }, + { + id: "nav-reports", + title: "Go to Reports", + keywords: ["reports", "analytics", "stats"], + icon: FileBarChart, + category: "navigation", + run: navigateTo("/reports"), + shortcut: "g+r", + }, + { + id: "nav-settings", + title: "Go to Settings", + keywords: ["settings", "preferences", "config"], + icon: Settings, + category: "navigation", + run: navigateTo("/settings"), + shortcut: "g+s", + }, + { + id: "nav-agents-activity", + title: "Go to Agent Activity", + keywords: ["agents", "activity", "logs"], + icon: Bot, + category: "navigation", + run: navigateTo("/agents/activity"), + }, + + // ── Create ───────────────────────────────────────────────────────────── + { + id: "create-task", + title: "New Task", + keywords: ["task", "todo", "new", "create"], + icon: ListTodo, + category: "create", + run: openCreate("task", "/tasks"), + shortcut: "n+t", + }, + { + id: "create-habit", + title: "New Habit", + keywords: ["habit", "streak", "new", "create"], + icon: Flame, + category: "create", + run: openCreate("habit", "/habits"), + shortcut: "n+h", + }, + { + id: "create-project", + title: "New Project", + keywords: ["project", "new", "create"], + icon: FolderKanban, + category: "create", + run: openCreate("project", "/projects"), + shortcut: "n+p", + }, + { + id: "create-note", + title: "New Note", + keywords: ["note", "document", "new", "create"], + icon: NotebookPen, + category: "create", + run: openCreate("note", "/notes"), + shortcut: "n+n", + }, + { + id: "create-event", + title: "New Event", + keywords: ["event", "calendar", "new", "create"], + icon: CalendarDays, + category: "create", + run: openCreate("event", "/calendar"), + }, + + // ── Theme ────────────────────────────────────────────────────────────── + { + id: "theme-toggle", + title: "Toggle Light/Dark Mode", + keywords: ["theme", "dark", "light", "mode", "appearance"], + icon: Moon, + category: "theme", + run: toggleTheme(), + }, + + // ── Settings tabs ────────────────────────────────────────────────────── + { + id: "settings-appearance", + title: "Open Appearance Settings", + keywords: ["appearance", "theme", "font", "density"], + icon: Sun, + category: "settings", + run: navigateToSettingsTab("appearance"), + }, + { + id: "settings-domains", + title: "Open Domains Settings", + keywords: ["domains", "workspaces"], + icon: Globe, + category: "settings", + run: navigateToSettingsTab("domains"), + }, + { + id: "settings-tags", + title: "Open Tags Settings", + keywords: ["tags", "labels"], + icon: Tag, + category: "settings", + run: navigateToSettingsTab("tags"), + }, + { + id: "settings-custom-fields", + title: "Open Custom Fields Settings", + keywords: ["custom", "fields", "schema"], + icon: List, + category: "settings", + run: navigateToSettingsTab("custom-fields"), + }, + { + id: "settings-shortcuts", + title: "Open Keyboard Shortcuts Settings", + keywords: ["keyboard", "shortcuts", "bindings", "keys"], + icon: Key, + category: "settings", + run: navigateToSettingsTab("shortcuts"), + }, + { + id: "settings-agents", + title: "Open Agents Settings", + keywords: ["agents", "permissions", "api"], + icon: Bot, + category: "settings", + run: navigateToSettingsTab("agents"), + }, + { + id: "settings-webhooks", + title: "Open Webhooks Settings", + keywords: ["webhooks", "integrations"], + icon: Webhook, + category: "settings", + run: navigateToSettingsTab("webhooks"), + }, + { + id: "settings-import-export", + title: "Open Import/Export Settings", + keywords: ["import", "export", "backup", "data"], + icon: Upload, + category: "settings", + run: navigateToSettingsTab("import-export"), + }, + + // ── Help ─────────────────────────────────────────────────────────────── + { + id: "help-shortcuts", + title: "Show Keyboard Shortcuts", + keywords: ["shortcuts", "help", "keys", "bindings"], + icon: Key, + category: "help", + run: dispatchEvent("open-shortcuts-help"), + }, + { + id: "open-palette", + title: "Open Command Palette", + keywords: ["palette", "command", "search"], + icon: Plus, + category: "help", + run: dispatchEvent("open-command-palette"), + }, +]; diff --git a/apps/web/src/lib/shortcuts/registry.ts b/apps/web/src/lib/shortcuts/registry.ts new file mode 100644 index 0000000..ccfac32 --- /dev/null +++ b/apps/web/src/lib/shortcuts/registry.ts @@ -0,0 +1,150 @@ +export interface ShortcutDefinition { + id: string; + defaultKeys: string; // @github/hotkey format + description: string; + category: "navigation" | "create" | "general" | "palette"; + commandId: string; // references a Command.id +} + +export const shortcuts: ShortcutDefinition[] = [ + // ── Navigation (g+ sequences) ────────────────────────────────────────── + { + id: "nav-dashboard", + defaultKeys: "g d", + description: "Go to Dashboard", + category: "navigation", + commandId: "nav-dashboard", + }, + { + id: "nav-tasks", + defaultKeys: "g t", + description: "Go to Tasks", + category: "navigation", + commandId: "nav-tasks", + }, + { + id: "nav-habits", + defaultKeys: "g h", + description: "Go to Habits", + category: "navigation", + commandId: "nav-habits", + }, + { + id: "nav-projects", + defaultKeys: "g p", + description: "Go to Projects", + category: "navigation", + commandId: "nav-projects", + }, + { + id: "nav-notes", + defaultKeys: "g n", + description: "Go to Notes", + category: "navigation", + commandId: "nav-notes", + }, + { + id: "nav-calendar", + defaultKeys: "g c", + description: "Go to Calendar", + category: "navigation", + commandId: "nav-calendar", + }, + { + id: "nav-graph", + defaultKeys: "g g", + description: "Go to Graph", + category: "navigation", + commandId: "nav-graph", + }, + { + id: "nav-reports", + defaultKeys: "g r", + description: "Go to Reports", + category: "navigation", + commandId: "nav-reports", + }, + { + id: "nav-settings", + defaultKeys: "g s", + description: "Go to Settings", + category: "navigation", + commandId: "nav-settings", + }, + { + id: "nav-inbox", + defaultKeys: "g i", + description: "Go to Inbox", + category: "navigation", + commandId: "nav-inbox", + }, + + // ── Create (n+ sequences) ────────────────────────────────────────────── + { + id: "create-task", + defaultKeys: "n t", + description: "New Task", + category: "create", + commandId: "create-task", + }, + { + id: "create-habit", + defaultKeys: "n h", + description: "New Habit", + category: "create", + commandId: "create-habit", + }, + { + id: "create-project", + defaultKeys: "n p", + description: "New Project", + category: "create", + commandId: "create-project", + }, + { + id: "create-note", + defaultKeys: "n n", + description: "New Note", + category: "create", + commandId: "create-note", + }, + + // ── General ──────────────────────────────────────────────────────────── + { + id: "help-shortcuts", + defaultKeys: "?", + description: "Show Keyboard Shortcuts", + category: "general", + commandId: "help-shortcuts", + }, + + // ── Palette ──────────────────────────────────────────────────────────── + { + id: "palette-open", + defaultKeys: "/", + description: "Open Command Palette", + category: "palette", + commandId: "open-palette", + }, + { + id: "palette-open-c", + defaultKeys: "c", + description: "Open Command Palette", + category: "palette", + commandId: "open-palette", + }, + { + id: "palette-cmd-k", + defaultKeys: "Meta+k", + description: "Open Command Palette", + category: "palette", + commandId: "open-palette", + }, + { + id: "new-task-cmd-n", + defaultKeys: "Meta+n", + description: "New Task", + category: "create", + commandId: "create-task", + }, +]; diff --git a/apps/web/src/lib/shortcuts/use-shortcut-manager.ts b/apps/web/src/lib/shortcuts/use-shortcut-manager.ts new file mode 100644 index 0000000..f1fe635 --- /dev/null +++ b/apps/web/src/lib/shortcuts/use-shortcut-manager.ts @@ -0,0 +1,190 @@ +import { useEffect, useRef, useCallback } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { install, uninstall } from "@github/hotkey"; +import { shortcuts, type ShortcutDefinition } from "./registry"; +import { useCommandStore } from "@/lib/commands"; +import type { CommandContext } from "@/lib/commands/registry"; + +const REMAPS_KEY = "project-e-shortcut-remaps"; + +function loadRemaps(): Record { + try { + return JSON.parse(localStorage.getItem(REMAPS_KEY) || "{}"); + } catch { + return {}; + } +} + +function saveRemaps(remaps: Record) { + localStorage.setItem(REMAPS_KEY, JSON.stringify(remaps)); +} + +function getEffectiveKeys(shortcut: ShortcutDefinition, remaps: Record): string { + return remaps[shortcut.id] || shortcut.defaultKeys; +} + +function findCollision( + shortcutId: string, + newKeys: string, + remaps: Record, +): ShortcutDefinition | null { + for (const s of shortcuts) { + if (s.id === shortcutId) continue; + const effective = getEffectiveKeys(s, remaps); + if (effective === newKeys) return s; + } + return null; +} + +/** + * Hook that installs all keyboard shortcuts from the registry. + * Uses @github/hotkey for multi-key sequences and a global keydown + * handler for modifier combos (Cmd/Ctrl + key). + * + * Must be called inside a React component (uses hooks). + */ +export function useShortcutManager(enabled = true) { + const navigate = useNavigate(); + const containerRef = useRef(null); + const remapsRef = useRef>(loadRemaps()); + + const runCommand = useCommandStore((s) => s.run); + + const buildContext = useCallback( + (): CommandContext => ({ + navigate: (opts) => navigate(opts), + }), + [navigate], + ); + + useEffect(() => { + if (!enabled) return; + + const ctx = buildContext(); + + // Hidden container for @github/hotkey synthetic elements + const container = document.createElement("div"); + container.setAttribute("aria-hidden", "true"); + container.style.display = "none"; + document.body.appendChild(container); + containerRef.current = container; + + const hotkeyElements: HTMLElement[] = []; + + // Install @github/hotkey for sequences and single keys + for (const shortcut of shortcuts) { + const keys = getEffectiveKeys(shortcut, remapsRef.current); + + // Skip modifier combos — they're handled by keydown below + if ( + keys.includes("Meta+") || + keys.includes("Control+") || + keys.includes("Alt+") || + keys.includes("Shift+") + ) { + continue; + } + + const el = document.createElement("span"); + el.setAttribute("data-hotkey", keys); + el.addEventListener("hotkey-fire", (e: Event) => { + e.preventDefault(); + runCommand(shortcut.commandId, ctx); + }); + container.appendChild(el); + install(el, keys); + hotkeyElements.push(el); + } + + // Global keydown handler for modifier combos (Cmd/Ctrl+K, Cmd/Ctrl+N) + const handleKeyDown = (e: KeyboardEvent) => { + // Never override native behavior inside controls or modal UI + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable || + target.closest( + '[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]', + ) + ) { + return; + } + + const isMod = e.metaKey || e.ctrlKey; + if (!isMod) return; + + for (const shortcut of shortcuts) { + const keys = getEffectiveKeys(shortcut, remapsRef.current); + if (!keys.includes("Meta+") && !keys.includes("Control+")) continue; + + // Parse the key from the shortcut definition + // e.g., "Meta+k" → "k", "Ctrl+n" → "n" + const parts = keys.split("+"); + const shortcutKey = parts[parts.length - 1].toLowerCase(); + + if (e.key.toLowerCase() === shortcutKey) { + e.preventDefault(); + runCommand(shortcut.commandId, ctx); + return; + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + + // Cleanup + return () => { + document.removeEventListener("keydown", handleKeyDown); + for (const el of hotkeyElements) { + uninstall(el); + } + document.body.removeChild(container); + containerRef.current = null; + }; + }, [enabled, buildContext, runCommand]); + + /** Save a custom key binding for a shortcut. Returns false if there's a collision. */ + const saveRemap = useCallback( + (shortcutId: string, newKeys: string): boolean => { + const remaps = { ...remapsRef.current }; + + // Check for collision with another shortcut + const collision = findCollision(shortcutId, newKeys, remaps); + if (collision) { + return false; + } + + remaps[shortcutId] = newKeys; + remapsRef.current = remaps; + saveRemaps(remaps); + + // Trigger re-render by reloading the page (shortcuts need reinstall) + window.location.reload(); + return true; + }, + [], + ); + + /** Reset a shortcut to its default keys. */ + const resetRemap = useCallback((shortcutId: string) => { + const remaps = { ...remapsRef.current }; + delete remaps[shortcutId]; + remapsRef.current = remaps; + saveRemaps(remaps); + + // Trigger re-render by reloading the page + window.location.reload(); + }, []); + + /** Get the effective keys for a shortcut (with remaps applied). */ + const getKeys = useCallback( + (shortcut: ShortcutDefinition): string => { + return getEffectiveKeys(shortcut, remapsRef.current); + }, + [], + ); + + return { saveRemap, resetRemap, getKeys }; +} diff --git a/apps/web/src/lib/stores/use-command-history-store.ts b/apps/web/src/lib/stores/use-command-history-store.ts new file mode 100644 index 0000000..b1a8384 --- /dev/null +++ b/apps/web/src/lib/stores/use-command-history-store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface CommandHistoryState { + history: string[]; // command IDs, most recent first + record: (commandId: string) => void; + getRecent: (count?: number) => string[]; +} + +export const useCommandHistoryStore = create()( + persist( + (set, get) => ({ + history: [], + record: (commandId) => + set((state) => ({ + history: [ + commandId, + ...state.history.filter((id) => id !== commandId), + ].slice(0, 20), + })), + getRecent: (count = 5) => get().history.slice(0, count), + }), + { + name: "project-e-command-history", + } + ) +); diff --git a/apps/web/src/lib/stores/use-create-dialog-store.ts b/apps/web/src/lib/stores/use-create-dialog-store.ts index c29ad5a..9a5ab29 100644 --- a/apps/web/src/lib/stores/use-create-dialog-store.ts +++ b/apps/web/src/lib/stores/use-create-dialog-store.ts @@ -2,16 +2,26 @@ import { create } from "zustand"; type ItemType = "task" | "project" | "habit" | "note" | "event" | null; +export interface CreatePrefill { + title?: string; + dueDate?: string; + priority?: string; + tags?: string[]; +} + interface CreateDialogState { type: ItemType; open: boolean; - openCreate: (type: ItemType) => void; + prefill: CreatePrefill | null; + openCreate: (type: ItemType, prefill?: CreatePrefill) => void; closeCreate: () => void; } export const useCreateDialogStore = create((set) => ({ type: null, open: false, - openCreate: (type: ItemType) => set({ type, open: true }), - closeCreate: () => set({ type: null, open: false }), + prefill: null, + openCreate: (type: ItemType, prefill?: CreatePrefill) => + set({ type, open: true, prefill: prefill || null }), + closeCreate: () => set({ type: null, open: false, prefill: null }), })); diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx index 1ff186d..0b41eab 100644 --- a/apps/web/src/routes/_app/settings.tsx +++ b/apps/web/src/routes/_app/settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { createRoute } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -398,18 +398,239 @@ function CustomFieldsTab() { // ─── Keyboard Shortcuts Tab ──────────────────────────────────────────────── +import { shortcuts as shortcutRegistry, type ShortcutDefinition } from "@/lib/shortcuts/registry"; + +function loadRemaps(): Record { + try { + return JSON.parse(localStorage.getItem("project-e-shortcut-remaps") || "{}"); + } catch { + return {}; + } +} + +function saveRemaps(remaps: Record) { + localStorage.setItem("project-e-shortcut-remaps", JSON.stringify(remaps)); + window.dispatchEvent(new CustomEvent("shortcuts-changed")); +} + +function formatKeyCombo(keys: string[]): string { + return keys.join("+"); +} + function ShortcutsTab() { + const [remaps, setRemaps] = useState>(loadRemaps); + const [recordingId, setRecordingId] = useState(null); + const [pendingKeys, setPendingKeys] = useState([]); + const [collisionWarning, setCollisionWarning] = useState(null); + const recordingRef = useRef(null); + + const grouped = useMemo(() => { + const map = new Map(); + for (const s of shortcutRegistry) { + const cat = s.category || "general"; + if (!map.has(cat)) map.set(cat, []); + map.get(cat)!.push(s); + } + return map; + }, []); + + const categoryLabels: Record = { + navigation: "Navigation", + create: "Create", + general: "General", + palette: "Palette", + }; + + // Keydown handler while recording + useEffect(() => { + if (!recordingId) return; + + const handler = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + setRecordingId(null); + setPendingKeys([]); + setCollisionWarning(null); + return; + } + + if (e.key === "Enter") { + if (pendingKeys.length === 0) return; + + const combo = formatKeyCombo(pendingKeys); + // Check for collisions with other shortcuts + const existing = shortcutRegistry.find((s) => { + if (s.id === recordingId) return false; + const currentKeys = remaps[s.id] || s.defaultKeys; + return currentKeys === combo; + }); + if (existing) { + setCollisionWarning( + `"${combo}" is already assigned to "${existing.description}". Choose a different combination.` + ); + return; + } + + setRemaps((prev) => { + const next = { ...prev, [recordingId]: combo }; + saveRemaps(next); + return next; + }); + setRecordingId(null); + setPendingKeys([]); + setCollisionWarning(null); + return; + } + + // Build key combo from event + const parts: string[] = []; + if (e.ctrlKey || e.metaKey) parts.push("Mod"); + if (e.altKey) parts.push("Alt"); + if (e.shiftKey) parts.push("Shift"); + const key = e.key.toLowerCase(); + if (!["control", "meta", "alt", "shift"].includes(key)) { + parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key); + } + setPendingKeys(parts); + }; + + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [recordingId, pendingKeys, remaps]); + + const handleReset = (id: string) => { + setRemaps((prev) => { + const next = { ...prev }; + delete next[id]; + saveRemaps(next); + return next; + }); + }; + + const getCurrentKeys = (shortcut: ShortcutDefinition): string => { + return remaps[shortcut.id] || shortcut.defaultKeys; + }; + return (
-

Keyboard Shortcuts

-
- {Object.entries(SHORTCUTS_MAP).map(([key, desc]) => ( -
- {desc} - {key} -
- ))} +
+

Keyboard Shortcuts

+
+

+ Click "Record" to remap a shortcut. Press your desired key combination, then Enter to save or Esc to cancel. +

+ + {recordingId && ( +
+
+

Recording shortcut for:

+

+ {shortcutRegistry.find((s) => s.id === recordingId)?.description} +

+
+
+ + {pendingKeys.length > 0 ? formatKeyCombo(pendingKeys) : "Press keys..."} + + +
+
+ )} + + {collisionWarning && ( +
+ {collisionWarning} +
+ )} + + {Array.from(grouped.entries()).map(([category, shortcuts]) => ( +
+

+ {categoryLabels[category] || category} +

+
+ {shortcuts.map((shortcut) => { + const isRecording = recordingId === shortcut.id; + const isRemapped = !!remaps[shortcut.id]; + return ( +
+ {shortcut.description} +
+ + {getCurrentKeys(shortcut)} + + {isRecording ? ( + Listening... + ) : ( +
+ + {isRemapped && ( + + )} +
+ )} +
+
+ ); + })} +
+
+ ))}
); } @@ -894,6 +1115,16 @@ function ErrorLogTab() { function SettingsPage() { const [activeTab, setActiveTab] = useState("appearance"); + // Listen for open-settings-tab event dispatched by the command registry + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (typeof detail === "string") setActiveTab(detail); + }; + document.addEventListener("open-settings-tab", handler); + return () => document.removeEventListener("open-settings-tab", handler); + }, []); + return (
{/* Tab bar - horizontal scrollable on mobile, vertical sidebar on md+ */} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 8196bb0..c905884 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -53,7 +53,7 @@ export default { }, }, borderRadius: { - lg: "var(--radius)", + lg: "10px", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..47c5467 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/user/projects/dev/ProjectE/node_modules \ No newline at end of file