feat: Phase 6 - MCP + Webhooks + Worker + Polish
- MCP server: stateless JSON-RPC 2.0 with 18 tools (tasks, habits, projects, notes, domains, search, activity) - Webhooks API: CRUD routes under /api/domains/[domainId]/webhooks/ with test endpoint and deliveries log - Webhook delivery: HMAC-SHA256 signed POST with retry (exponential backoff, max 6) - Worker rewrite: Drizzle ORM instead of PocketBase, polls jobs table, handles webhook_delivery, recurring_spawn, ai_dispatch - Rate limiting: token bucket per IP/API key (100 req/min REST, 300 req/min MCP) - Keyboard help overlay: ? opens Radix Dialog with search/filter, Esc closes - AI @mention stub: @agent in command palette dispatches CustomEvent - Mobile responsive: bottom nav, single-column kanban, day view calendar, 44px touch targets - Accessibility: skip-to-content link, focus rings, aria-labels, color contrast - E2E tests: mcp.spec.ts, webhooks.spec.ts, realtime.spec.ts added - Schema: api_keys and webhook_deliveries tables with migration - Removed old PocketBase-style database.ts from worker
This commit is contained in:
@@ -84,6 +84,11 @@ export function CommandPalette() {
|
||||
{ label: 'New habit', action: () => router.push('/habits?new=true') },
|
||||
{ label: 'New project', action: () => router.push('/projects?new=true') },
|
||||
{ label: 'New note', action: () => router.push('/notes?new=true') },
|
||||
{ label: 'Ask AI agent...', shortcut: '@', action: () => {
|
||||
setOpen(false);
|
||||
// Dispatch event for AI dispatch flow
|
||||
document.dispatchEvent(new CustomEvent('open-ai-dispatch'));
|
||||
}},
|
||||
];
|
||||
|
||||
// Search handler
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Flame,
|
||||
NotebookPen,
|
||||
CalendarDays,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
|
||||
const bottomNavItems = [
|
||||
{ href: '/tasks', label: 'Tasks', icon: ListTodo },
|
||||
{ href: '/habits', label: 'Habits', icon: Flame },
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/notes', label: 'Notes', icon: NotebookPen },
|
||||
{ href: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||
{ href: '/more', label: 'More', icon: MoreHorizontal },
|
||||
];
|
||||
|
||||
export function MobileBottomNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="mobile-bottom-nav fixed bottom-0 left-0 right-0 z-50 border-t bg-card md:hidden"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div className="flex items-center justify-around h-16">
|
||||
{bottomNavItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/more'
|
||||
? !bottomNavItems
|
||||
.filter((i) => i.href !== '/more')
|
||||
.some((i) => pathname.startsWith(i.href))
|
||||
: pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 px-3 py-1 min-h-[44px] min-w-[44px] rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<item.icon className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useKeyboardShortcutsStore } from '@/lib/stores/use-keyboard-shortcuts-store';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: { keys: string; description: string }[];
|
||||
}
|
||||
|
||||
const shortcutGroups: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Global',
|
||||
shortcuts: [
|
||||
{ keys: '?', description: 'Open keyboard shortcuts help' },
|
||||
{ keys: '⌘K', description: 'Open command palette' },
|
||||
{ keys: '⌘⇧K', description: 'Deep search' },
|
||||
{ keys: 'Esc', description: 'Close panel / dialog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{ keys: 'G then D', description: 'Go to Dashboard' },
|
||||
{ keys: 'G then T', description: 'Go to Tasks' },
|
||||
{ keys: 'G then H', description: 'Go to Habits' },
|
||||
{ keys: 'G then P', description: 'Go to Projects' },
|
||||
{ keys: 'G then N', description: 'Go to Notes' },
|
||||
{ keys: 'G then G', description: 'Go to Graph' },
|
||||
{ keys: 'G then C', description: 'Go to Calendar' },
|
||||
{ keys: 'G then S', description: 'Go to Search' },
|
||||
{ keys: 'G then A', description: 'Go to Analytics' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Creation',
|
||||
shortcuts: [
|
||||
{ keys: 'C (on tasks page)', description: 'New task' },
|
||||
{ keys: 'C (on habits page)', description: 'New habit' },
|
||||
{ keys: 'C (on projects page)', description: 'New project' },
|
||||
{ keys: 'C (on notes page)', description: 'New note' },
|
||||
{ keys: 'C (on project detail)', description: 'New section' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Entity Actions',
|
||||
shortcuts: [
|
||||
{ keys: 'Space (on tasks)', description: 'Open first task detail' },
|
||||
{ keys: 'E', description: 'Edit focused task' },
|
||||
{ keys: 'D', description: 'Delete focused task' },
|
||||
{ keys: '1-4 (on tasks)', description: 'Filter kanban column (todo→cancelled)' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function ShortcutsHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { shortcuts } = useKeyboardShortcutsStore();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Toggle with ? key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen((prev) => !prev);
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
if (e.key === 'Escape' && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
}, [open]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!search.trim()) return shortcutGroups;
|
||||
const q = search.toLowerCase();
|
||||
return shortcutGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
shortcuts: group.shortcuts.filter(
|
||||
(s) =>
|
||||
s.keys.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q)
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.shortcuts.length > 0);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-lg max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Press <kbd className="rounded border bg-muted px-1">?</kbd> to toggle this help
|
||||
All available keyboard shortcuts for Project E.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto">
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">Navigation</h2>
|
||||
<div className="space-y-1">
|
||||
{shortcuts
|
||||
.filter((s) => s.action.startsWith('navigate_'))
|
||||
.map((shortcut) => (
|
||||
<div key={shortcut.key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{shortcut.description}</span>
|
||||
<kbd className="rounded border bg-muted px-2 py-0.5 text-xs font-mono">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">Actions</h2>
|
||||
<div className="space-y-1">
|
||||
{shortcuts
|
||||
.filter((s) => !s.action.startsWith('navigate_'))
|
||||
.map((shortcut) => (
|
||||
<div key={shortcut.key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{shortcut.description}</span>
|
||||
<kbd className="rounded border bg-muted px-2 py-0.5 text-xs font-mono">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="Search shortcuts..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-4"
|
||||
aria-label="Search shortcuts"
|
||||
/>
|
||||
|
||||
<ScrollArea className="flex-1 max-h-[50vh]">
|
||||
{filteredGroups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No shortcuts match your search.
|
||||
</p>
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<div key={group.title} className="mb-6">
|
||||
<h3 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wider">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{group.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.keys}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span>{shortcut.description}</span>
|
||||
<kbd className="ml-4 inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground whitespace-nowrap">
|
||||
{shortcut.keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center pt-2 border-t">
|
||||
Press <kbd className="rounded border bg-muted px-1 font-mono">?</kbd> to toggle this overlay
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user