webhook-list.tsx
1 'use client' 2 3 import { useEffect, useMemo, useState } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { copyTextToClipboard } from '@/lib/clipboard' 6 7 function webhookPath(id: string): string { 8 return `/api/webhooks/${id}` 9 } 10 11 function formatEvents(events: string[] | undefined): string { 12 const list = Array.isArray(events) ? events.filter(Boolean) : [] 13 if (list.length === 0) return 'all events' 14 if (list.length <= 2) return list.join(', ') 15 return `${list.slice(0, 2).join(', ')}, +${list.length - 2}` 16 } 17 18 export function WebhookList({ inSidebar }: { inSidebar?: boolean }) { 19 const webhooks = useAppStore((s) => s.webhooks) 20 const loadWebhooks = useAppStore((s) => s.loadWebhooks) 21 const setWebhookSheetOpen = useAppStore((s) => s.setWebhookSheetOpen) 22 const setEditingWebhookId = useAppStore((s) => s.setEditingWebhookId) 23 const agents = useAppStore((s) => s.agents) 24 const loadAgents = useAppStore((s) => s.loadAgents) 25 const [copied, setCopied] = useState<string | null>(null) 26 27 useEffect(() => { 28 loadWebhooks() 29 loadAgents() 30 }, [loadWebhooks, loadAgents]) 31 32 const list = useMemo( 33 () => Object.values(webhooks).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)), 34 [webhooks] 35 ) 36 37 const copyText = async (key: string, value: string) => { 38 try { 39 const copiedValue = await copyTextToClipboard(value) 40 if (!copiedValue) return 41 setCopied(key) 42 setTimeout(() => setCopied((prev) => (prev === key ? null : prev)), 1400) 43 } catch { 44 // ignore clipboard failures (e.g. unsupported environment) 45 } 46 } 47 48 if (!list.length) { 49 return ( 50 <div className="flex-1 flex flex-col items-center justify-center gap-4 text-text-3 p-8 text-center" style={{ animation: 'fade-up 0.5s var(--ease-spring)' }}> 51 <div className="w-12 h-12 rounded-[14px] bg-white/[0.03] border border-white/[0.06] flex items-center justify-center mb-1"> 52 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3"> 53 <path d="M22 12h-4l-3 7L9 5l-3 7H2" /> 54 </svg> 55 </div> 56 <p className="text-[13px] text-text-3 mb-1 font-600">No webhooks yet</p> 57 <p className="text-[12px] text-text-3/60">Create inbound endpoints to trigger agent runs</p> 58 <button 59 onClick={() => { 60 setEditingWebhookId(null) 61 setWebhookSheetOpen(true) 62 }} 63 className="mt-3 px-4 py-2 rounded-[10px] bg-transparent text-accent-bright text-[13px] font-600 cursor-pointer border border-accent-bright/20 hover:bg-accent-soft transition-all" 64 style={{ fontFamily: 'inherit' }} 65 > 66 + Add Webhook 67 </button> 68 </div> 69 ) 70 } 71 72 return ( 73 <div className={`flex-1 overflow-y-auto ${inSidebar ? 'pb-10' : 'pb-20'}`}> 74 {list.map((hook, idx) => { 75 const agentName = hook.agentId ? agents[hook.agentId]?.name : null 76 const endpoint = webhookPath(hook.id) 77 const copiedEndpoint = copied === `endpoint:${hook.id}` 78 const copiedSecret = copied === `secret:${hook.id}` 79 const hasSecret = typeof hook.secret === 'string' && hook.secret.trim().length > 0 80 81 return ( 82 <div 83 key={hook.id} 84 className="w-full flex items-center gap-2.5 px-5 py-3 hover:bg-white/[0.02] transition-colors group" 85 style={{ 86 animation: 'fade-up 0.4s var(--ease-spring) both', 87 animationDelay: `${idx * 0.02}s` 88 }} 89 > 90 <button 91 onClick={() => { 92 setEditingWebhookId(hook.id) 93 setWebhookSheetOpen(true) 94 }} 95 className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer bg-transparent border-none text-left p-0" 96 > 97 <div className={`shrink-0 w-9 h-9 rounded-[10px] border flex items-center justify-center transition-all ${ 98 hook.isEnabled 99 ? 'bg-emerald-500/12 border-emerald-500/20 text-emerald-300' 100 : 'bg-white/[0.03] border-white/[0.08] text-text-3' 101 }`} 102 style={hook.isEnabled ? { animation: 'spring-in 0.4s var(--ease-spring)' } : undefined}> 103 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 104 <path d="M22 12h-4l-3 7L9 5l-3 7H2" /> 105 </svg> 106 </div> 107 108 <div className="flex-1 min-w-0"> 109 <div className="flex items-center gap-2"> 110 <span className="text-[13px] font-600 text-text truncate">{hook.name || 'Unnamed Webhook'}</span> 111 <span className={`shrink-0 w-2 h-2 rounded-full ${hook.isEnabled ? 'bg-emerald-400' : 'bg-white/20'}`} 112 style={hook.isEnabled ? { animation: 'pulse-subtle 2s infinite' } : undefined} /> 113 </div> 114 <div className="text-[11px] text-text-3 truncate"> 115 {hook.source || 'custom'} · {formatEvents(hook.events)}{agentName ? ` · ${agentName}` : ''} 116 </div> 117 </div> 118 </button> 119 120 <button 121 onClick={(e) => { 122 e.stopPropagation() 123 copyText(`endpoint:${hook.id}`, `${window.location.origin}${endpoint}`) 124 }} 125 title={copiedEndpoint ? 'Copied endpoint' : 'Copy endpoint URL'} 126 className={`shrink-0 w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer border-none ${ 127 copiedEndpoint 128 ? 'opacity-100 bg-emerald-500/15 text-emerald-300' 129 : 'opacity-0 group-hover:opacity-100 focus:opacity-100 bg-accent-soft/40 text-accent-bright hover:bg-accent-soft' 130 } hover:scale-[1.1] active:scale-[0.9]`} 131 > 132 {copiedEndpoint ? ( 133 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ animation: 'spring-in 0.3s var(--ease-spring)' }}> 134 <polyline points="20 6 9 17 4 12" /> 135 </svg> 136 ) : ( 137 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 138 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" /> 139 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> 140 </svg> 141 )} 142 </button> 143 144 {hasSecret && ( 145 <button 146 onClick={(e) => { 147 e.stopPropagation() 148 copyText(`secret:${hook.id}`, hook.secret!.trim()) 149 }} 150 title={copiedSecret ? 'Copied secret' : 'Copy secret'} 151 className={`shrink-0 w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer border-none ${ 152 copiedSecret 153 ? 'opacity-100 bg-emerald-500/15 text-emerald-300' 154 : 'opacity-0 group-hover:opacity-100 focus:opacity-100 bg-white/[0.04] text-text-2 hover:bg-white/[0.08]' 155 } hover:scale-[1.1] active:scale-[0.9]`} 156 > 157 {copiedSecret ? ( 158 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ animation: 'spring-in 0.3s var(--ease-spring)' }}> 159 <polyline points="20 6 9 17 4 12" /> 160 </svg> 161 ) : ( 162 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 163 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> 164 <path d="M7 11V7a5 5 0 0 1 10 0v4" /> 165 </svg> 166 )} 167 </button> 168 )} 169 </div> 170 ) 171 })} 172 </div> 173 ) 174 }