/ src / components / webhooks / webhook-list.tsx
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  }