T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker

- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui
   - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs
   - apps/worker: Bun worker stub, DB connection, graceful SIGTERM
   - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference)
   - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy)
   - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api
   - docker-compose.yml: 4-service target (api, spa, db, worker)
   - packages/db/src/client.ts: shared Drizzle client for api + worker
   - db/client.ts: root-level alias for convenience

   Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
Hermes
2026-08-01 01:15:31 +00:00
parent 9203aee758
commit fca56ab77e
312 changed files with 3489 additions and 196 deletions
@@ -0,0 +1,245 @@
'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';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
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');
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
fetchAgents();
}, []);
async function fetchAgents() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/agents');
if (!response.ok) throw new Error('Unable to load agents.');
const data = await response.json();
setAgents(data.items || []);
} catch (error) {
console.error('Failed to fetch agents:', error);
setError('Unable to load agents. Please try again.');
} finally {
setLoading(false);
}
}
async function createAgent() {
if (!newAgentName.trim()) return;
setCreating(true);
setError(null);
setStatus(null);
try {
const response = await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newAgentName,
permission_tier: newAgentTier,
status: 'active',
}),
});
if (!response.ok) throw new Error('Unable to create agent.');
setNewAgentName('');
setCreateDialogOpen(false);
setStatus('Agent created successfully.');
await fetchAgents();
} catch (error) {
console.error('Failed to create agent:', error);
setError('Unable to create agent. Please try again.');
} finally {
setCreating(false);
}
}
async function deleteAgent() {
if (!agentToDelete) return;
setDeletingId(agentToDelete.id);
setError(null);
setStatus(null);
try {
const response = await fetch(`/api/agents/${agentToDelete.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete agent.');
setAgentToDelete(null);
setStatus('Agent deleted successfully.');
await fetchAgents();
} catch (error) {
console.error('Failed to delete agent:', error);
setError('Unable to delete agent. Please try again.');
} finally {
setDeletingId(null);
}
}
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>
{error && (
<div className="mb-4 flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={fetchAgents} disabled={loading}>Retry</Button>
</div>
)}
{status && <p className="mb-4 text-sm text-muted-foreground" role="status">{status}</p>}
{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={() => setAgentToDelete(agent)}
aria-label={`Delete agent: ${agent.name}`}
disabled={deletingId === agent.id}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)}
<AlertDialog open={!!agentToDelete} onOpenChange={(open) => !open && setAgentToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {agentToDelete?.name}?</AlertDialogTitle>
<AlertDialogDescription>This will revoke the agent&apos;s access.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!deletingId}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={deleteAgent} disabled={!!deletingId}>
{deletingId ? 'Deleting...' : 'Delete agent'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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,254 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Trash2, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const STORAGE_KEY = 'pe_custom_field_schemas';
type FieldType = 'text' | 'number' | 'select' | 'checkbox';
type FieldScope = 'task' | 'habit' | 'project' | 'note';
interface FieldSchema {
id: string;
name: string;
type: FieldType;
options?: string[];
scope: FieldScope;
}
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Number' },
{ value: 'select', label: 'Select' },
{ value: 'checkbox', label: 'Checkbox' },
];
const SCOPE_OPTIONS: { value: FieldScope; label: string }[] = [
{ value: 'task', label: 'Task' },
{ value: 'habit', label: 'Habit' },
{ value: 'project', label: 'Project' },
{ value: 'note', label: 'Note' },
];
function loadSchemas(): FieldSchema[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveSchemas(schemas: FieldSchema[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(schemas));
}
export function SettingsCustomFields() {
const [schemas, setSchemas] = useState<FieldSchema[]>([]);
const [filterScope, setFilterScope] = useState<FieldScope | 'all'>('all');
// Create form
const [newName, setNewName] = useState('');
const [newType, setNewType] = useState<FieldType>('text');
const [newScope, setNewScope] = useState<FieldScope>('task');
const [newOptions, setNewOptions] = useState('');
// Delete
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
setSchemas(loadSchemas());
}, []);
const filteredSchemas = filterScope === 'all'
? schemas
: schemas.filter((s) => s.scope === filterScope);
function handleAdd() {
if (!newName.trim()) return;
const newSchema: FieldSchema = {
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: newName.trim(),
type: newType,
scope: newScope,
options: newType === 'select' ? newOptions.split(',').map((o) => o.trim()).filter(Boolean) : undefined,
};
const updated = [...schemas, newSchema];
saveSchemas(updated);
setSchemas(updated);
toast.success('Custom field added');
setNewName('');
setNewType('text');
setNewOptions('');
}
function handleDelete(id: string) {
const updated = schemas.filter((s) => s.id !== id);
saveSchemas(updated);
setSchemas(updated);
setDeletingId(null);
toast.success('Custom field removed');
}
return (
<Card>
<CardHeader>
<CardTitle>Custom Fields</CardTitle>
<CardDescription>
Define custom field schemas for tasks, habits, projects, and notes. Stored locally in your browser.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Scope filter */}
<div className="flex items-center gap-2">
<Label htmlFor="cf-filter-scope" className="text-xs">Filter by scope:</Label>
<Select value={filterScope} onValueChange={(v) => setFilterScope(v as FieldScope | 'all')}>
<SelectTrigger id="cf-filter-scope" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All scopes</SelectItem>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Schema list */}
<div className="space-y-2">
{filteredSchemas.length === 0 ? (
<p className="text-sm text-muted-foreground">
{schemas.length === 0
? 'No custom fields defined yet. Add one below.'
: 'No custom fields match the selected scope.'}
</p>
) : (
filteredSchemas.map((field) => (
<div key={field.id} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<div>
<span className="font-medium">{field.name}</span>
<div className="flex gap-1 mt-1">
<Badge variant="secondary" className="text-xs">{field.type}</Badge>
<Badge variant="outline" className="text-xs capitalize">{field.scope}</Badge>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setDeletingId(field.id)}
aria-label={`Delete custom field: ${field.name}`}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
))
)}
</div>
{/* Add form */}
<div className="flex flex-wrap items-end gap-2 rounded-lg border p-3">
<div className="flex-1 space-y-1">
<Label htmlFor="new-cf-name" className="text-xs">Name</Label>
<Input
id="new-cf-name"
placeholder="Field name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
<div className="w-32 space-y-1">
<Label htmlFor="new-cf-type" className="text-xs">Type</Label>
<Select value={newType} onValueChange={(v) => setNewType(v as FieldType)}>
<SelectTrigger id="new-cf-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-32 space-y-1">
<Label htmlFor="new-cf-scope" className="text-xs">Scope</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as FieldScope)}>
<SelectTrigger id="new-cf-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newType === 'select' && (
<div className="w-48 space-y-1">
<Label htmlFor="new-cf-options" className="text-xs">Options (comma-separated)</Label>
<Input
id="new-cf-options"
placeholder="Option A, Option B"
value={newOptions}
onChange={(e) => setNewOptions(e.target.value)}
/>
</div>
)}
<Button onClick={handleAdd} disabled={!newName.trim()}>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
{/* Delete confirmation */}
<AlertDialog open={!!deletingId} onOpenChange={(open) => !open && setDeletingId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove custom field?</AlertDialogTitle>
<AlertDialogDescription>
This removes the field definition from your browser storage. Existing task data is not affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletingId && handleDelete(deletingId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}
@@ -0,0 +1,290 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2, Pencil } 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';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
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 [newDomainColor, setNewDomainColor] = useState('#3b82f6');
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [domainToDelete, setDomainToDelete] = useState<Domain | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [editing, setEditing] = useState<Domain | null>(null);
const [editName, setEditName] = useState('');
const [editColor, setEditColor] = useState('#3b82f6');
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchDomains();
}, []);
useEffect(() => {
if (editing) {
setEditName(editing.name);
setEditColor(editing.color || '#3b82f6');
}
}, [editing]);
async function fetchDomains() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/domains?sort=sort_order');
if (!response.ok) throw new Error('Unable to load domains.');
const data = await response.json();
setDomains(data.items || []);
} catch (error) {
console.error('Failed to fetch domains:', error);
setError('Unable to load domains. Please try again.');
} finally {
setLoading(false);
}
}
async function addDomain() {
if (!newDomainName.trim()) return;
setCreating(true);
setError(null);
setStatus(null);
try {
const response = await fetch('/api/domains', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newDomainName,
color: newDomainColor,
icon: '📁',
sort_order: domains.length,
}),
});
if (!response.ok) throw new Error('Unable to add domain.');
setNewDomainName('');
setStatus('Domain added successfully.');
await fetchDomains();
} catch (error) {
console.error('Failed to add domain:', error);
setError('Unable to add domain. Please try again.');
} finally {
setCreating(false);
}
}
async function deleteDomain() {
if (!domainToDelete) return;
setDeletingId(domainToDelete.id);
setError(null);
setStatus(null);
try {
const response = await fetch('/api/domains/' + domainToDelete.id, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete domain.');
setDomainToDelete(null);
setStatus('Domain deleted successfully.');
await fetchDomains();
} catch (error) {
console.error('Failed to delete domain:', error);
setError('Unable to delete domain. Please try again.');
} finally {
setDeletingId(null);
}
}
async function handleEditSave() {
if (!editing || !editName.trim()) return;
setSaving(true);
setError(null);
setStatus(null);
try {
const response = await fetch('/api/domains/' + editing.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName.trim(),
color: editColor,
}),
});
if (!response.ok) throw new Error('Unable to update domain.');
setEditing(null);
setStatus('Domain updated successfully.');
await fetchDomains();
} catch (error) {
console.error('Failed to update domain:', error);
setError('Unable to update domain. Please try again.');
} finally {
setSaving(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Domains</CardTitle>
<CardDescription>Manage your workspace domains (e.g., Personal, Work, OTS).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={fetchDomains} disabled={loading}>Retry</Button>
</div>
)}
{status && <p className="text-sm text-muted-foreground" role="status">{status}</p>}
{/* 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>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditing(domain)}
aria-label={'Edit domain: ' + domain.name}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDomainToDelete(domain)}
aria-label={'Delete domain: ' + domain.name}
disabled={deletingId === domain.id}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
</div>
))
)}
</div>
{/* Add new domain */}
<div className="relative z-50 flex gap-2">
<label htmlFor="new-domain-name" className="sr-only">
New domain name
</label>
<div className="flex gap-2 flex-1">
<Input
id="new-domain-name"
placeholder="New domain name"
value={newDomainName}
onChange={(e) => setNewDomainName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
disabled={creating}
className="flex-1"
/>
<input
type="color"
value={newDomainColor}
onChange={(e) => setNewDomainColor(e.target.value)}
className="h-10 w-10 cursor-pointer rounded border"
title="Domain color"
disabled={creating}
/>
</div>
<Button type="button" onClick={(e) => { e.stopPropagation(); addDomain(); }} disabled={creating || !newDomainName.trim()}>
<Plus className="mr-1 h-4 w-4" />
{creating ? 'Adding...' : 'Add'}
</Button>
</div>
{/* Edit Domain Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit domain</DialogTitle>
<DialogDescription>Update the domain name and color.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-domain-name">Name</Label>
<Input
id="edit-domain-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-domain-color">Color</Label>
<input
id="edit-domain-color"
type="color"
value={editColor}
onChange={(e) => setEditColor(e.target.value)}
className="h-10 w-10 cursor-pointer rounded border"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
<Button onClick={handleEditSave} disabled={saving || !editName.trim()}>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!domainToDelete} onOpenChange={(open) => !open && setDomainToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {domainToDelete?.name}?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!deletingId}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={deleteDomain} disabled={!!deletingId}>
{deletingId ? 'Deleting...' : 'Delete domain'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}
@@ -0,0 +1,414 @@
'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);
const [exportError, setExportError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
// ── Export ────────────────────────────────────────────────────────────────
async function handleExport() {
setExporting(true);
setExportProgress(0);
setExportError(null);
let progressInterval: ReturnType<typeof setInterval> | undefined;
try {
// Simulate progress while fetching
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);
progressInterval = undefined;
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);
setExportError('Failed to export data. Please try again.');
toast.error('Failed to export data');
} finally {
if (progressInterval) clearInterval(progressInterval);
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);
setImportError(null);
setConfirmImport(true);
}
async function executeImport() {
if (!pendingImportFile) return;
setConfirmImport(false);
setImporting(true);
setImportProgress(0);
setImportError(null);
let progressInterval: ReturnType<typeof setInterval> | undefined;
try {
const text = await pendingImportFile.text();
const data = JSON.parse(text);
if (!data.version) {
setImportError('Invalid file. Select a valid Project E export and try again.');
toast.error('Invalid file — missing version field. Is this a valid Project E export?');
return;
}
// Simulate progress
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);
progressInterval = undefined;
if (!response.ok) {
let message = 'Import failed. Please try again.';
try {
const errorData = await response.json();
message = errorData.error?.message || message;
} catch {
// Use the default message when the server does not return JSON.
}
setImportError(message);
toast.error(message);
return;
}
const result: ImportResult = await response.json();
setImportProgress(100);
setImportResult(result);
setPendingImportFile(null);
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);
setImportError('Failed to parse or import the file. Please try again.');
toast.error('Failed to parse import file. Please check the format.');
} finally {
if (progressInterval) clearInterval(progressInterval);
setImporting(false);
}
}
// ── 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" aria-label={`Export progress: ${exportProgress}%`} />
<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>
{exportError && <p className="mt-2 text-sm text-destructive" role="alert">{exportError}</p>}
</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" aria-label={`Import progress: ${importProgress}%`} />
<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>
{importError && (
<div className="mt-2 flex items-center gap-3 text-sm text-destructive" role="alert">
<span>{importError}</span>
{pendingImportFile && <Button variant="outline" size="sm" onClick={executeImport} disabled={importing}>Retry import</Button>}
</div>
)}
</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,65 @@
'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';
import { NotificationPrefs } from '@/components/notifications/notification-prefs';
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>
{/* Notification Preferences */}
<div className="pt-4 border-t">
<h3 className="font-semibold mb-1">Notification Preferences</h3>
<p className="text-sm text-muted-foreground mb-4">
Choose which events trigger in-app notifications.
</p>
<NotificationPrefs />
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,353 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2, Pencil, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface Tag {
id: string;
name: string;
color?: string | null;
scope?: string | null;
}
const SCOPE_OPTIONS = ['global', 'tasks', 'habits', 'projects', 'notes'] as const;
export function SettingsTags() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Create form
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState('#3b82f6');
const [newScope, setNewScope] = useState('global');
const [creating, setCreating] = useState(false);
// Edit dialog
const [editing, setEditing] = useState<Tag | null>(null);
const [editName, setEditName] = useState('');
const [editColor, setEditColor] = useState('#3b82f6');
const [editScope, setEditScope] = useState('global');
const [saving, setSaving] = useState(false);
// Delete confirmation
const [deleting, setDeleting] = useState<Tag | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchTags();
}, []);
useEffect(() => {
if (editing) {
setEditName(editing.name);
setEditColor(editing.color || '#3b82f6');
setEditScope(editing.scope || 'global');
}
}, [editing]);
async function fetchTags() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/tags?sort=name');
if (!response.ok) throw new Error('Unable to load tags.');
const data = await response.json();
setTags(data.items || []);
} catch (err) {
console.error('Failed to fetch tags:', err);
setError('Unable to load tags. Please try again.');
} finally {
setLoading(false);
}
}
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
const response = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.trim(),
color: newColor,
scope: newScope,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || 'Unable to create tag.');
}
toast.success('Tag created');
setNewName('');
setNewColor('#3b82f6');
setNewScope('global');
await fetchTags();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unable to create tag.');
} finally {
setCreating(false);
}
}
async function handleEditSave() {
if (!editing || !editName.trim()) return;
setSaving(true);
try {
const response = await fetch(`/api/tags/${editing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName.trim(),
color: editColor,
scope: editScope,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || 'Unable to update tag.');
}
toast.success('Tag updated');
setEditing(null);
await fetchTags();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unable to update tag.');
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!deleting) return;
setDeletingId(deleting.id);
try {
const response = await fetch(`/api/tags/${deleting.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete tag.');
toast.success('Tag deleted');
setDeleting(null);
await fetchTags();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unable to delete tag.');
} finally {
setDeletingId(null);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
<CardDescription>Manage tags for organizing tasks, habits, projects, and notes.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={fetchTags} disabled={loading}>
Retry
</Button>
</div>
)}
{/* Tag list */}
<div className="space-y-2">
{loading ? (
<p className="text-sm text-muted-foreground">Loading tags...</p>
) : tags.length === 0 ? (
<p className="text-sm text-muted-foreground">No tags yet. Create one below.</p>
) : (
tags.map((tag) => (
<div key={tag.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-full"
style={{ backgroundColor: tag.color || '#6b7280' }}
/>
<span className="font-medium">{tag.name}</span>
{tag.scope && (
<Badge variant="secondary" className="text-xs capitalize">
{tag.scope}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditing(tag)}
aria-label={`Edit tag: ${tag.name}`}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(tag)}
aria-label={`Delete tag: ${tag.name}`}
disabled={deletingId === tag.id}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
</div>
))
)}
</div>
{/* Create form */}
<div className="flex flex-wrap items-end gap-2 rounded-lg border p-3">
<div className="flex-1 space-y-1">
<Label htmlFor="new-tag-name" className="text-xs">Name</Label>
<Input
id="new-tag-name"
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
disabled={creating}
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-tag-color" className="text-xs">Color</Label>
<input
id="new-tag-color"
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="h-10 w-10 cursor-pointer rounded border"
disabled={creating}
/>
</div>
<div className="w-32 space-y-1">
<Label htmlFor="new-tag-scope" className="text-xs">Scope</Label>
<Select value={newScope} onValueChange={setNewScope} disabled={creating}>
<SelectTrigger id="new-tag-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s} value={s} className="capitalize">
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
<Plus className="mr-1 h-4 w-4" />
{creating ? 'Adding...' : 'Add'}
</Button>
</div>
{/* Edit Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit tag</DialogTitle>
<DialogDescription>Update the tag name, color, and scope.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-tag-name">Name</Label>
<Input
id="edit-tag-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-tag-color">Color</Label>
<input
id="edit-tag-color"
type="color"
value={editColor}
onChange={(e) => setEditColor(e.target.value)}
className="h-10 w-10 cursor-pointer rounded border"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-tag-scope">Scope</Label>
<Select value={editScope} onValueChange={setEditScope}>
<SelectTrigger id="edit-tag-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s} value={s} className="capitalize">
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button onClick={handleEditSave} disabled={saving || !editName.trim()}>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<AlertDialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &ldquo;{deleting?.name}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this tag. It will be removed from all associated items.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!deletingId}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={!!deletingId}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deletingId ? 'Deleting...' : 'Delete tag'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}
@@ -0,0 +1,487 @@
'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();
const statusCode = data.status || 0;
const responseBody = data.response || '';
const preview = responseBody.substring(0, 200);
if (data.success) {
toast.success(`Test delivery succeeded (${statusCode})`, {
description: preview || undefined,
});
} else {
toast.error(`Test delivery failed (${statusCode})`, {
description: preview || undefined,
});
}
} 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>
);
}