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:
@@ -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 "New webhook" 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user