refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests

- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories
- Add Dockerfiles for web, worker, and PocketBase services
- Add docker-compose.yml for local orchestration
- Add turbo.json for monorepo task management
- Add Playwright e2e test infrastructure
- Add PocketBase backend with migrations
- Remove Vite/Next.js/ESLint/PostCSS config files
- Update package.json with workspace dependencies
- Add .env.example and .dockerignore
This commit is contained in:
2026-07-16 06:19:58 -04:00
parent ec14645a4b
commit 8f55626e03
286 changed files with 31992 additions and 9245 deletions
@@ -0,0 +1,189 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Copy, Trash2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Agent {
id: string;
name: string;
api_key: string;
permission_tier: string;
status: 'active' | 'disabled';
}
export function SettingsAgents() {
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newAgentName, setNewAgentName] = useState('');
const [newAgentTier, setNewAgentTier] = useState('read_only');
useEffect(() => {
fetchAgents();
}, []);
async function fetchAgents() {
try {
const response = await fetch('/api/agents');
if (response.ok) {
const data = await response.json();
setAgents(data.items || []);
}
} catch (error) {
console.error('Failed to fetch agents:', error);
} finally {
setLoading(false);
}
}
async function createAgent() {
if (!newAgentName.trim()) return;
try {
await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newAgentName,
permission_tier: newAgentTier,
status: 'active',
}),
});
setNewAgentName('');
setCreateDialogOpen(false);
fetchAgents();
} catch (error) {
console.error('Failed to create agent:', error);
}
}
async function deleteAgent(id: string) {
if (!confirm('Are you sure? This will revoke the agent\'s access.')) return;
try {
await fetch(`/api/agents/${id}`, { method: 'DELETE' });
fetchAgents();
} catch (error) {
console.error('Failed to delete agent:', error);
}
}
function copyApiKey(apiKey: string) {
navigator.clipboard.writeText(apiKey);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Agents & Permissions</CardTitle>
<CardDescription>Manage AI agents and their access levels.</CardDescription>
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-1 h-4 w-4" />
New agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="agent-name">Name</Label>
<Input
id="agent-name"
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
placeholder="e.g., Hermes, Claude"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-tier">Permission tier</Label>
<Select value={newAgentTier} onValueChange={setNewAgentTier}>
<SelectTrigger id="agent-tier">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full_access">Full Access</SelectItem>
<SelectItem value="read_only">Read Only</SelectItem>
<SelectItem value="content_creator">Content Creator</SelectItem>
<SelectItem value="task_manager">Task Manager</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={createAgent} className="w-full">
Create agent
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Loading agents...
</p>
) : agents.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No agents configured
</p>
) : (
<div className="space-y-3">
{agents.map((agent) => (
<div key={agent.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{agent.name}</h3>
<Badge variant={agent.status === 'active' ? 'default' : 'secondary'}>
{agent.status}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{agent.permission_tier.replace('_', ' ')}
</p>
<div className="mt-2 flex items-center gap-2">
<code className="rounded bg-muted px-2 py-1 text-xs">
{agent.api_key.slice(0, 8)}...
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyApiKey(agent.api_key)}
aria-label={`Copy API key for ${agent.name}`}
>
<Copy className="h-3 w-3" aria-hidden="true" />
</Button>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteAgent(agent.id)}
aria-label={`Delete agent: ${agent.name}`}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,105 @@
'use client';
import { useThemeStore } from '@/lib/stores/use-theme-store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { ACCENT_COLORS, FONTS, DENSITIES, THEME_MODES } from '@/lib/theme';
export function SettingsAppearance() {
const { mode, accent, font, density, reducedMotion, setMode, setAccent, setFont, setDensity, setReducedMotion } = useThemeStore();
return (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize how Project E looks and feels.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Theme mode */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Theme</Label>
<Select value={mode} onValueChange={(v) => setMode(v as 'light' | 'dark' | 'system')}>
<SelectTrigger id="theme-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THEME_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Accent color */}
<div className="space-y-2">
<Label>Accent color</Label>
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Accent color">
{ACCENT_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setAccent(color.value)}
className={cn(
'h-8 w-8 rounded-full border-2 transition-all',
accent === color.value ? 'border-foreground scale-110' : 'border-transparent'
)}
style={{ backgroundColor: color.value }}
title={color.name}
aria-label={`${color.name} accent color`}
role="radio"
aria-checked={accent === color.value}
/>
))}
</div>
</div>
{/* Font */}
<div className="space-y-2">
<Label htmlFor="settings-font">Font</Label>
<Select value={font} onValueChange={setFont}>
<SelectTrigger id="settings-font">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONTS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Density */}
<div className="space-y-2">
<Label htmlFor="settings-density">Density</Label>
<Select value={density} onValueChange={(v) => setDensity(v as 'compact' | 'comfortable' | 'spacious')}>
<SelectTrigger id="settings-density">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DENSITIES.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Reduced motion */}
<div className="flex items-center justify-between">
<div>
<Label>Reduced motion</Label>
<p className="text-sm text-muted-foreground">Minimize animations and transitions</p>
</div>
<Switch checked={reducedMotion} onCheckedChange={setReducedMotion} aria-label="Reduced motion" />
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface Domain {
id: string;
name: string;
color: string;
icon: string;
sort_order: number;
}
export function SettingsDomains() {
const [domains, setDomains] = useState<Domain[]>([]);
const [newDomainName, setNewDomainName] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDomains();
}, []);
async function fetchDomains() {
try {
const response = await fetch('/api/domains?sort=sort_order');
if (response.ok) {
const data = await response.json();
setDomains(data.items || []);
}
} catch (error) {
console.error('Failed to fetch domains:', error);
} finally {
setLoading(false);
}
}
async function addDomain() {
if (!newDomainName.trim()) return;
try {
await fetch('/api/domains', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newDomainName,
color: '#3b82f6',
icon: '📁',
sort_order: domains.length,
}),
});
setNewDomainName('');
fetchDomains();
} catch (error) {
console.error('Failed to add domain:', error);
}
}
async function deleteDomain(id: string) {
if (!confirm('Are you sure? This cannot be undone.')) return;
try {
await fetch(`/api/domains/${id}`, { method: 'DELETE' });
fetchDomains();
} catch (error) {
console.error('Failed to delete domain:', error);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Domains</CardTitle>
<CardDescription>Manage your workspace domains (e.g., Personal, Work, OTS).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing domains */}
<div className="space-y-2">
{loading ? (
<p className="text-sm text-muted-foreground">Loading domains...</p>
) : (
domains.map((domain) => (
<div key={domain.id} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded"
style={{ backgroundColor: domain.color }}
/>
<span className="font-medium">{domain.name}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteDomain(domain.id)}
aria-label={`Delete domain: ${domain.name}`}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
))
)}
</div>
{/* Add new domain */}
<div className="flex gap-2">
<label htmlFor="new-domain-name" className="sr-only">
New domain name
</label>
<Input
id="new-domain-name"
placeholder="New domain name"
value={newDomainName}
onChange={(e) => setNewDomainName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
/>
<Button onClick={addDomain}>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,386 @@
'use client';
import { useState, useEffect } from 'react';
import { Download, Upload, Check, AlertCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
// ── Types ──────────────────────────────────────────────────────────────────
interface CollectionInfo {
name: string;
label: string;
}
interface ImportResultItem {
collection: string;
imported: number;
failed: number;
errors: string[];
}
interface ImportResult {
success: boolean;
imported: number;
failed: number;
results: ImportResultItem[];
}
const DEFAULT_COLLECTIONS: CollectionInfo[] = [
{ name: 'tasks', label: 'Tasks' },
{ name: 'habits', label: 'Habits' },
{ name: 'projects', label: 'Projects' },
{ name: 'notes', label: 'Notes' },
{ name: 'reports', label: 'Reports' },
{ name: 'milestones', label: 'Milestones' },
{ name: 'domains', label: 'Domains' },
{ name: 'tags', label: 'Tags' },
{ name: 'agents', label: 'Agents' },
{ name: 'webhooks', label: 'Webhooks' },
];
// ── Main Component ─────────────────────────────────────────────────────────
export function SettingsImportExport() {
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [importProgress, setImportProgress] = useState(0);
const [selectedCollections, setSelectedCollections] = useState<string[]>(
DEFAULT_COLLECTIONS.map((c) => c.name)
);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
// ── Export ────────────────────────────────────────────────────────────────
async function handleExport() {
setExporting(true);
setExportProgress(0);
try {
// Simulate progress while fetching
const progressInterval = setInterval(() => {
setExportProgress((prev) => Math.min(prev + 10, 90));
}, 200);
const response = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collections: selectedCollections }),
});
clearInterval(progressInterval);
if (!response.ok) {
throw new Error('Export failed');
}
const data = await response.json();
setExportProgress(100);
// Download as JSON
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `project-e-export-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('Export completed successfully');
} catch (error) {
console.error('Failed to export:', error);
toast.error('Failed to export data');
} finally {
setExporting(false);
setExportProgress(0);
}
}
// ── Import ────────────────────────────────────────────────────────────────
function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
// Reset the input so the same file can be selected again
event.target.value = '';
if (!file.name.endsWith('.json')) {
toast.error('Please select a JSON file');
return;
}
setPendingImportFile(file);
setImportResult(null);
setConfirmImport(true);
}
async function executeImport() {
if (!pendingImportFile) return;
setConfirmImport(false);
setImporting(true);
setImportProgress(0);
try {
const text = await pendingImportFile.text();
const data = JSON.parse(text);
if (!data.version) {
toast.error('Invalid file — missing version field. Is this a valid Project E export?');
return;
}
// Simulate progress
const progressInterval = setInterval(() => {
setImportProgress((prev) => Math.min(prev + 5, 90));
}, 300);
const response = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
clearInterval(progressInterval);
if (!response.ok) {
const errorData = await response.json();
toast.error(errorData.error?.message || 'Import failed');
return;
}
const result: ImportResult = await response.json();
setImportProgress(100);
setImportResult(result);
if (result.success) {
toast.success(`Import complete: ${result.imported} records imported`);
} else {
toast.warning(
`Import finished with errors: ${result.imported} imported, ${result.failed} failed`
);
}
} catch (error) {
console.error('Failed to import:', error);
toast.error('Failed to parse import file. Please check the format.');
} finally {
setImporting(false);
setPendingImportFile(null);
}
}
// ── Collection toggle ─────────────────────────────────────────────────────
function toggleCollection(name: string) {
setSelectedCollections((prev) =>
prev.includes(name) ? prev.filter((c) => c !== name) : [...prev, name]
);
}
function toggleAllCollections() {
if (selectedCollections.length === DEFAULT_COLLECTIONS.length) {
setSelectedCollections([]);
} else {
setSelectedCollections(DEFAULT_COLLECTIONS.map((c) => c.name));
}
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<Card>
<CardHeader>
<CardTitle>Import & Export</CardTitle>
<CardDescription>Backup your data or restore from a previous export.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* ── Export Section ─────────────────────────────────────────────────── */}
<div className="rounded-lg border p-4">
<h3 className="font-semibold">Export data</h3>
<p className="mt-1 text-sm text-muted-foreground">
Download your data as a JSON file. Choose which collections to include.
</p>
{/* Collection selection */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="select-all"
checked={selectedCollections.length === DEFAULT_COLLECTIONS.length}
onCheckedChange={toggleAllCollections}
/>
<Label htmlFor="select-all" className="text-sm font-medium">
Select all
</Label>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
{DEFAULT_COLLECTIONS.map((collection) => (
<div key={collection.name} className="flex items-center gap-2">
<Checkbox
id={`export-${collection.name}`}
checked={selectedCollections.includes(collection.name)}
onCheckedChange={() => toggleCollection(collection.name)}
/>
<Label
htmlFor={`export-${collection.name}`}
className="text-sm text-muted-foreground"
>
{collection.label}
</Label>
</div>
))}
</div>
</div>
{/* Progress */}
{exporting && (
<div className="mt-4 space-y-2">
<Progress value={exportProgress} className="h-2" />
<p className="text-xs text-muted-foreground">Exporting... {exportProgress}%</p>
</div>
)}
<Button
onClick={handleExport}
disabled={exporting || selectedCollections.length === 0}
className="mt-4"
>
{exporting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{exporting ? 'Exporting...' : 'Export to JSON'}
</Button>
</div>
{/* ── Import Section ─────────────────────────────────────────────────── */}
<div className="rounded-lg border p-4">
<h3 className="font-semibold">Import data</h3>
<p className="mt-1 text-sm text-muted-foreground">
Restore from a previously exported JSON file. All existing data will be supplemented.
</p>
{/* Progress */}
{importing && (
<div className="mt-4 space-y-2">
<Progress value={importProgress} className="h-2" />
<p className="text-xs text-muted-foreground">Importing... {importProgress}%</p>
</div>
)}
{/* Import results */}
{importResult && (
<div className="mt-4 rounded-lg border p-3">
<div className="flex items-center gap-2">
{importResult.success ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
<span className="text-sm font-medium">
{importResult.imported} imported, {importResult.failed} failed
</span>
</div>
{importResult.results.length > 0 && (
<div className="mt-3 space-y-2">
{importResult.results.map((r) => (
<div key={r.collection} className="flex items-center justify-between text-sm">
<span className="capitalize text-muted-foreground">{r.collection}</span>
<span>
{r.imported} ok
{r.failed > 0 && (
<span className="text-destructive">, {r.failed} failed</span>
)}
</span>
</div>
))}
</div>
)}
{importResult.results.some((r) => r.errors.length > 0) && (
<div className="mt-3">
<p className="text-xs font-medium text-destructive">Errors:</p>
<div className="mt-1 max-h-32 overflow-auto" role="list" aria-label="Import errors">
{importResult.results
.flatMap((r) => r.errors)
.slice(0, 10)
.map((error, i) => (
<p key={i} className="text-xs text-muted-foreground">
{error}
</p>
))}
</div>
</div>
)}
</div>
)}
<label className="mt-4 inline-block">
<input
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
disabled={importing}
/>
<Button variant="outline" disabled={importing} asChild>
<span>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{importing ? 'Importing...' : 'Import from JSON'}
</span>
</Button>
</label>
</div>
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */}
<AlertDialog open={confirmImport} onOpenChange={setConfirmImport}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm import</AlertDialogTitle>
<AlertDialogDescription>
This will import data from the selected file. Existing records will not be
overwritten, but new records will be created for each item in the file. Are you sure
you want to proceed?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingImportFile(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={executeImport}>
<Upload className="mr-2 h-4 w-4" />
Import
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}
@@ -0,0 +1,55 @@
'use client';
import { useKeyboardShortcutsStore } from '@/lib/stores/use-keyboard-shortcuts-store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
export function SettingsShortcuts() {
const { enabled, shortcuts, setEnabled, resetShortcuts } = useKeyboardShortcutsStore();
return (
<Card>
<CardHeader>
<CardTitle>Keyboard Shortcuts</CardTitle>
<CardDescription>Customize keyboard shortcuts for quick navigation and actions.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Enable/disable all */}
<div className="flex items-center justify-between">
<div>
<Label>Enable keyboard shortcuts</Label>
<p className="text-sm text-muted-foreground">
Turn off all keyboard shortcuts globally
</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} aria-label="Enable keyboard shortcuts" />
</div>
{/* Shortcuts list */}
<div className="space-y-2">
{shortcuts.map((shortcut) => (
<div
key={shortcut.key}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{shortcut.description}</p>
<p className="text-xs text-muted-foreground">{shortcut.action}</p>
</div>
<kbd className="rounded border bg-muted px-2 py-1 text-sm font-mono">
{shortcut.key}
</kbd>
</div>
))}
</div>
{/* Reset button */}
<Button variant="outline" onClick={resetShortcuts}>
Reset to defaults
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,479 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Trash2, RotateCcw, Send, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
// ── Types ──────────────────────────────────────────────────────────────────
interface Webhook {
id: string;
name: string;
url: string;
events: string[];
active: boolean;
secret?: string;
domain?: string;
retry_count: number;
last_triggered_at?: string;
created: string;
updated: string;
}
interface WebhookDelivery {
id: string;
webhook_id: string;
event_type: string;
payload: Record<string, unknown>;
success: boolean;
response_status: number;
response_body: string;
attempts: number;
created: string;
}
const AVAILABLE_EVENTS = [
'*',
'task.completed',
'habit.completed',
'habit.streak_broken',
'milestone.reached',
'project.status_changed',
'report.generated',
'agent_task.completed',
];
// ── Webhook Delivery History ───────────────────────────────────────────────
function WebhookDeliveryHistory({ webhookId }: { webhookId?: string }) {
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([]);
const [loading, setLoading] = useState(true);
const [retryingId, setRetryingId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const fetchDeliveries = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ perPage: '50', sort: '-created' });
if (webhookId) params.set('webhook_id', webhookId);
const response = await fetch(`/api/webhook-deliveries?${params}`);
if (response.ok) {
const data = await response.json();
setDeliveries(data.items || []);
}
} catch (error) {
console.error('Failed to fetch deliveries:', error);
} finally {
setLoading(false);
}
}, [webhookId]);
useEffect(() => {
fetchDeliveries();
}, [fetchDeliveries]);
async function handleRetry(deliveryId: string) {
setRetryingId(deliveryId);
try {
const response = await fetch(`/api/webhook-deliveries/${deliveryId}/retry`, {
method: 'POST',
});
if (response.ok) {
toast.success('Retry queued');
fetchDeliveries();
} else {
const data = await response.json();
toast.error(data.error?.message || 'Failed to queue retry');
}
} catch (error) {
toast.error('Failed to queue retry');
} finally {
setRetryingId(null);
}
}
if (loading) {
return (
<p className="py-4 text-center text-sm text-muted-foreground">Loading delivery history...</p>
);
}
if (deliveries.length === 0) {
return (
<p className="py-4 text-center text-sm text-muted-foreground">
No deliveries yet. Send a test event or trigger an event to see delivery history.
</p>
);
}
return (
<ScrollArea className="max-h-[400px]">
<div className="space-y-2">
{deliveries.map((delivery) => (
<div key={delivery.id} className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setExpandedId(expandedId === delivery.id ? null : delivery.id)}
className="flex items-center gap-1 text-sm font-medium hover:text-primary"
aria-expanded={expandedId === delivery.id}
aria-label={`${expandedId === delivery.id ? 'Collapse' : 'Expand'} details for ${delivery.event_type}`}
>
{expandedId === delivery.id ? (
<ChevronDown className="h-3 w-3" aria-hidden="true" />
) : (
<ChevronRight className="h-3 w-3" aria-hidden="true" />
)}
{delivery.event_type}
</button>
<Badge variant={delivery.success ? 'default' : 'destructive'}>
{delivery.success ? 'Success' : 'Failed'}
</Badge>
{delivery.response_status > 0 && (
<Badge variant="outline">{delivery.response_status}</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{delivery.attempts} attempt{delivery.attempts !== 1 ? 's' : ''}
</span>
{!delivery.success && (
<Button
variant="ghost"
size="sm"
onClick={() => handleRetry(delivery.id)}
disabled={retryingId === delivery.id}
>
{retryingId === delivery.id ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<RotateCcw className="mr-1 h-3 w-3" />
)}
Retry
</Button>
)}
</div>
</div>
{expandedId === delivery.id && (
<div className="mt-3 space-y-2 border-t pt-3">
<div>
<Label className="text-xs text-muted-foreground">Timestamp</Label>
<p className="text-sm">{new Date(delivery.created).toLocaleString()}</p>
</div>
{delivery.response_body && (
<div>
<Label className="text-xs text-muted-foreground">Response</Label>
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted p-2 text-xs">
{delivery.response_body}
</pre>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground">Payload</Label>
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(delivery.payload, null, 2)}
</pre>
</div>
</div>
)}
</div>
))}
</div>
</ScrollArea>
);
}
// ── Main Component ─────────────────────────────────────────────────────────
export function SettingsWebhooks() {
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(true);
const [newWebhookName, setNewWebhookName] = useState('');
const [newWebhookUrl, setNewWebhookUrl] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('webhooks');
const fetchWebhooks = useCallback(async () => {
try {
const response = await fetch('/api/webhooks');
if (response.ok) {
const data = await response.json();
setWebhooks(data.items || []);
}
} catch (error) {
console.error('Failed to fetch webhooks:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchWebhooks();
}, [fetchWebhooks]);
async function addWebhook() {
if (!newWebhookName.trim() || !newWebhookUrl.trim()) return;
try {
const response = await fetch('/api/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newWebhookName,
url: newWebhookUrl,
events: ['*'],
active: true,
domain: 'default',
retry_count: 3,
}),
});
if (response.ok) {
toast.success('Webhook created');
setNewWebhookName('');
setNewWebhookUrl('');
setCreateDialogOpen(false);
fetchWebhooks();
} else {
const data = await response.json();
toast.error(data.error?.message || 'Failed to create webhook');
}
} catch (error) {
toast.error('Failed to create webhook');
}
}
async function toggleWebhook(webhook: Webhook) {
try {
const response = await fetch(`/api/webhooks/${webhook.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !webhook.active }),
});
if (response.ok) {
toast.success(webhook.active ? 'Webhook disabled' : 'Webhook enabled');
fetchWebhooks();
} else {
toast.error('Failed to update webhook');
}
} catch (error) {
toast.error('Failed to update webhook');
}
}
async function deleteWebhook(id: string) {
try {
await fetch(`/api/webhooks/${id}`, { method: 'DELETE' });
toast.success('Webhook deleted');
setDeleteConfirmId(null);
fetchWebhooks();
} catch (error) {
toast.error('Failed to delete webhook');
}
}
async function testWebhook(id: string) {
setTestingId(id);
try {
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
const data = await response.json();
if (data.success) {
toast.success(`Test delivery succeeded (${data.status})`);
} else {
toast.error(`Test delivery failed: ${data.response || 'Unknown error'}`);
}
} catch (error) {
toast.error('Failed to send test event');
} finally {
setTestingId(null);
}
}
return (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Webhooks</CardTitle>
<CardDescription>
Configure outbound webhooks for event notifications.
</CardDescription>
</div>
<div className="flex gap-2">
<TabsList>
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
<TabsTrigger value="deliveries">Delivery History</TabsTrigger>
</TabsList>
{activeTab === 'webhooks' && (
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-1 h-4 w-4" />
New webhook
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Webhook</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-name">Name</Label>
<Input
id="webhook-name"
value={newWebhookName}
onChange={(e) => setNewWebhookName(e.target.value)}
placeholder="e.g., Slack notifications"
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-url">URL</Label>
<Input
id="webhook-url"
value={newWebhookUrl}
onChange={(e) => setNewWebhookUrl(e.target.value)}
placeholder="https://example.com/webhook"
/>
</div>
<Button onClick={addWebhook} className="w-full">
Create webhook
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
</div>
</CardHeader>
<CardContent>
<TabsContent value="webhooks" className="mt-0">
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Loading webhooks...</p>
) : webhooks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No webhooks configured. Click &quot;New webhook&quot; to get started.
</p>
) : (
<div className="space-y-3">
{webhooks.map((webhook) => (
<div key={webhook.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{webhook.name}</h3>
<Badge variant={webhook.active ? 'default' : 'secondary'}>
{webhook.active ? 'Active' : 'Disabled'}
</Badge>
</div>
<p className="mt-1 font-mono text-sm text-muted-foreground">{webhook.url}</p>
<div className="mt-2 flex flex-wrap gap-1">
{webhook.events.slice(0, 4).map((event) => (
<Badge key={event} variant="outline" className="text-xs">
{event}
</Badge>
))}
{webhook.events.length > 4 && (
<Badge variant="outline" className="text-xs">
+{webhook.events.length - 4} more
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={webhook.active}
onCheckedChange={() => toggleWebhook(webhook)}
aria-label={`Toggle ${webhook.name}`}
/>
<Button
variant="ghost"
size="icon"
onClick={() => testWebhook(webhook.id)}
disabled={testingId === webhook.id || !webhook.active}
aria-label={`Send test event to ${webhook.name}`}
>
{testingId === webhook.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirmId(webhook.id)}
aria-label={`Delete webhook: ${webhook.name}`}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="deliveries" className="mt-0">
<WebhookDeliveryHistory />
</TabsContent>
</CardContent>
</Card>
{/* Delete confirmation dialog */}
<AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete webhook?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this webhook and all its delivery history. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteConfirmId && deleteWebhook(deleteConfirmId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Tabs>
);
}