refactor: move studio to frontends workspace

- Move studio from root to frontends/studio/
- Add owner-tools frontend for live blog admin UI
- Add shared ui component library
- Set up npm workspaces for frontends
- Add enhanced code block extension for editor

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Josh 2026-01-12 01:59:56 +02:00
parent c662e41b97
commit bef5dd4437
108 changed files with 8650 additions and 441 deletions

View file

@ -1,749 +0,0 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { $apiKeys, createAPIKey, $deleteAPIKey, $creating } from '../stores/apiKeys'
import { $webhooks, createWebhook, updateWebhook, $deleteWebhook, testWebhook, fetchWebhookDeliveries } from '../stores/webhooks'
import { $billing } from '../stores/billing'
import { addToast } from '../stores/app'
import { EmptyState, APIPageSkeleton, PageHeader } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button, Input, Modal, UsageIndicator } from '../components/ui'
import type { Webhook, WebhookDelivery, WebhookEvent } from '../types'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
interface QueryParam {
name: string
description: string
default?: string
options?: string[] // For select dropdowns
}
interface Endpoint {
method: HttpMethod
path: string
description: string
queryParams?: QueryParam[]
requestBody?: string
responseExample: string
}
const endpoints: Endpoint[] = [
{
method: 'GET',
path: '/api/v1/posts',
description: 'List published posts with pagination',
queryParams: [
{ name: 'limit', description: 'Results per page (max 100)', default: '20' },
{ name: 'offset', description: 'Skip N results', default: '0' },
{ name: 'tag', description: 'Filter by tag' },
{ name: 'include', description: 'Include extra fields', options: ['', 'content'] },
],
responseExample: `{
"posts": [
{
"id": "uuid",
"slug": "hello-world",
"title": "Hello World",
"description": "My first post",
"tags": ["intro"],
"date": "2024-01-15",
"draft": false
}
],
"total": 42,
"limit": 20,
"offset": 0
}`,
},
{
method: 'POST',
path: '/api/v1/posts',
description: 'Create a new post',
requestBody: `{
"title": "My New Post",
"slug": "my-new-post",
"content": "# Markdown content",
"description": "Optional description",
"tags": ["tutorial"],
"draft": false
}`,
responseExample: `{
"id": "uuid",
"slug": "my-new-post",
"title": "My New Post",
...
}`,
},
{
method: 'GET',
path: '/api/v1/posts/{slug}',
description: 'Get a single post by slug (includes content)',
responseExample: `{
"id": "uuid",
"slug": "hello-world",
"title": "Hello World",
"description": "My first post",
"content": {
"markdown": "# Full markdown...",
"html": "<h1>Full markdown...</h1>"
},
"tags": ["intro"],
"date": "2024-01-15"
}`,
},
{
method: 'PUT',
path: '/api/v1/posts/{slug}',
description: 'Update an existing post',
requestBody: `{
"title": "Updated Title",
"content": "# Updated content"
}`,
responseExample: `{
"id": "uuid",
"slug": "hello-world",
"title": "Updated Title",
...
}`,
},
{
method: 'DELETE',
path: '/api/v1/posts/{slug}',
description: 'Delete a post permanently',
responseExample: `204 No Content`,
},
{
method: 'GET',
path: '/api/v1/settings',
description: 'Get public site configuration',
responseExample: `{
"site_name": "My Blog",
"site_description": "A developer blog",
"author_name": "Jane Doe",
"author_bio": "Software engineer",
"twitter_handle": "janedoe",
"accent_color": "#10b981"
}`,
},
]
const methodColors: Record<HttpMethod, string> = {
GET: 'bg-success/15 text-success',
POST: 'bg-blue-500/15 text-blue-500',
PUT: 'bg-warning/15 text-warning',
DELETE: 'bg-danger/15 text-danger',
}
const webhookEvents: { value: WebhookEvent; label: string }[] = [
{ value: 'post.published', label: 'Post Published' },
{ value: 'post.updated', label: 'Post Updated' },
{ value: 'post.deleted', label: 'Post Deleted' },
]
function EndpointCard({ endpoint, baseUrl, apiKey }: { endpoint: Endpoint; baseUrl: string; apiKey?: string }) {
const [expanded, setExpanded] = useState(false)
const [slugInput, setSlugInput] = useState('hello-world')
const [requestBody, setRequestBody] = useState(endpoint.requestBody || '')
const [queryParams, setQueryParams] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}
endpoint.queryParams?.forEach(p => {
initial[p.name] = p.default || ''
})
return initial
})
const [response, setResponse] = useState<{ status: number; body: string; time: number } | null>(null)
const [loading, setLoading] = useState(false)
const basePath = endpoint.path.replace('{slug}', slugInput)
const queryString = Object.entries(queryParams)
.filter(([_, v]) => v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&')
const actualPath = queryString ? `${basePath}?${queryString}` : basePath
const fullUrl = `${baseUrl}${actualPath}`
const curlExample = endpoint.requestBody
? `curl -X ${endpoint.method} "${fullUrl}" \\
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}" \\
-H "Content-Type: application/json" \\
-d '${endpoint.requestBody.replace(/\n\s*/g, ' ')}'`
: `curl -X ${endpoint.method} "${fullUrl}" \\
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}"`
const copyCode = (code: string) => {
navigator.clipboard.writeText(code)
addToast('Copied to clipboard', 'success')
}
const updateQueryParam = (name: string, value: string) => {
setQueryParams(prev => ({ ...prev, [name]: value }))
}
const sendRequest = async () => {
setLoading(true)
setResponse(null)
const start = Date.now()
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
const res = await fetch(actualPath, {
method: endpoint.method,
headers,
body: endpoint.requestBody ? requestBody : undefined,
})
const time = Date.now() - start
let body: string
try {
const json = await res.json()
body = JSON.stringify(json, null, 2)
} catch {
body = res.status === 204 ? '(No Content)' : await res.text()
}
setResponse({ status: res.status, body, time })
} catch (err) {
setResponse({ status: 0, body: `Error: ${err instanceof Error ? err.message : 'Request failed'}`, time: Date.now() - start })
} finally {
setLoading(false)
}
}
return (
<div className="border border-border hover:border-muted transition-colors">
<button
onClick={() => setExpanded(!expanded)}
className="group w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/5 transition-colors cursor-pointer"
>
<span className={`px-2 py-0.5 text-xs font-mono font-medium ${methodColors[endpoint.method]}`}>
{endpoint.method}
</span>
<code className="text-sm font-mono flex-1 group-hover:text-accent transition-colors">{endpoint.path}</code>
<span className="text-xs text-muted hidden sm:block">{endpoint.description}</span>
<Icons.ChevronRight className={`text-muted transition-transform group-hover:text-text ${expanded ? 'rotate-90' : ''}`} />
</button>
{expanded && (
<div className="border-t border-border bg-muted/5 p-4 space-y-4">
<p className="text-sm text-muted">{endpoint.description}</p>
{endpoint.path.includes('{slug}') && (
<div className="flex items-center gap-2">
<label className="text-xs text-muted w-16">slug:</label>
<Input
value={slugInput}
onChange={setSlugInput}
className="max-w-48 text-sm"
placeholder="post-slug"
/>
</div>
)}
{endpoint.queryParams && endpoint.queryParams.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-muted">Query Parameters</div>
{endpoint.queryParams.map(param => (
<div key={param.name} className="flex items-center gap-2">
<label className="text-xs text-muted w-16 shrink-0">{param.name}:</label>
{param.options ? (
<select
value={queryParams[param.name] || ''}
onChange={e => updateQueryParam(param.name, e.target.value)}
className="px-2 py-1.5 text-sm bg-surface border border-border focus:border-accent focus:outline-none"
>
{param.options.map(opt => (
<option key={opt} value={opt}>{opt || '(none)'}</option>
))}
</select>
) : (
<Input
value={queryParams[param.name] || ''}
onChange={v => updateQueryParam(param.name, v)}
className="max-w-32 text-sm"
placeholder={param.default || ''}
/>
)}
<span className="text-xs text-muted">{param.description}</span>
</div>
))}
</div>
)}
{endpoint.requestBody && (
<div>
<div className="text-xs text-muted mb-2">Request Body</div>
<textarea
value={requestBody}
onChange={e => setRequestBody(e.target.value)}
className="w-full p-3 bg-surface border border-border text-xs font-mono resize-y min-h-32"
spellCheck={false}
/>
</div>
)}
<Button
variant="primary"
Icon={loading ? undefined : Icons.Play}
onClick={sendRequest}
loading={loading}
>
{loading ? 'Sending...' : 'Try it'}
</Button>
{response && (
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-xs text-muted">Response</span>
<span className={`px-2 py-0.5 text-xs font-mono ${response.status >= 200 && response.status < 300 ? 'bg-success/15 text-success' : response.status >= 400 ? 'bg-danger/15 text-danger' : 'bg-warning/15 text-warning'}`}>
{response.status || 'Error'}
</span>
<span className="text-xs text-muted">{response.time}ms</span>
</div>
<pre className="p-3 bg-surface border border-border text-xs font-mono max-h-64 overflow-y-auto whitespace-pre-wrap break-words">
{response.body}
</pre>
</div>
)}
<div>
<div className="text-xs text-muted mb-2">Example Response</div>
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
{endpoint.responseExample}
</pre>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted">cURL</span>
<button onClick={() => copyCode(curlExample)} className="text-xs text-muted hover:text-text">
<Icons.Copy className="inline mr-1" />Copy
</button>
</div>
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto whitespace-pre-wrap">
{curlExample}
</pre>
</div>
</div>
)}
</div>
)
}
function WebhookCard({ webhook, onEdit, onDelete, onTest }: {
webhook: Webhook
onEdit: () => void
onDelete: () => void
onTest: () => void
}) {
const [showLogs, setShowLogs] = useState(false)
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([])
const [loadingLogs, setLoadingLogs] = useState(false)
const loadDeliveries = async () => {
setLoadingLogs(true)
try {
const data = await fetchWebhookDeliveries(webhook.id)
setDeliveries(data)
setShowLogs(true)
} catch {
addToast('Failed to load delivery logs', 'error')
} finally {
setLoadingLogs(false)
}
}
return (
<div className="border border-border p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full ${webhook.enabled ? (webhook.last_status === 'success' ? 'bg-success' : webhook.last_status === 'failed' ? 'bg-danger' : 'bg-muted') : 'bg-muted'}`} />
<span className="font-medium text-sm">{webhook.name}</span>
{!webhook.enabled && <span className="text-xs text-muted">(disabled)</span>}
</div>
<code className="text-xs text-muted break-all">{webhook.url}</code>
<div className="flex flex-wrap gap-1 mt-2">
{webhook.events.map(event => (
<span key={event} className="px-1.5 py-0.5 text-xs bg-muted/20 text-muted">
{event}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={loadDeliveries} className="p-1.5 text-muted hover:text-text" title="View logs">
<Icons.List className="w-4 h-4" />
</button>
<button onClick={onTest} className="p-1.5 text-muted hover:text-text" title="Test">
<Icons.Play className="w-4 h-4" />
</button>
<button onClick={onEdit} className="p-1.5 text-muted hover:text-text" title="Edit">
<Icons.Edit className="w-4 h-4" />
</button>
<button onClick={onDelete} className="p-1.5 text-danger hover:text-danger/80" title="Delete">
<Icons.Trash className="w-4 h-4" />
</button>
</div>
</div>
{showLogs && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted">Recent Deliveries</span>
<button onClick={() => setShowLogs(false)} className="text-xs text-muted hover:text-text">Close</button>
</div>
{loadingLogs ? (
<div className="text-xs text-muted">Loading...</div>
) : deliveries.length === 0 ? (
<div className="text-xs text-muted">No deliveries yet</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{deliveries.slice(0, 10).map(d => (
<div key={d.id} className="flex items-center gap-2 text-xs">
<span className={`w-2 h-2 rounded-full ${d.status === 'success' ? 'bg-success' : 'bg-danger'}`} />
<span className="text-muted">{d.event}</span>
<span className="text-muted"></span>
<span className={d.status === 'success' ? 'text-success' : 'text-danger'}>
{d.response_code || d.status}
</span>
<span className="text-muted ml-auto">
{new Date(d.created_at).toLocaleString()}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
export default function APIPage() {
const { data: keys } = useStore($apiKeys)
const { data: webhooks } = useStore($webhooks)
const { data: billing } = useStore($billing)
const creating = useStore($creating)
const deleteKey = useStore($deleteAPIKey)
const deleteWebhookMutation = useStore($deleteWebhook)
const [showCreateKey, setShowCreateKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [deleteKeyModal, setDeleteKeyModal] = useState<string | null>(null)
const [showWebhookModal, setShowWebhookModal] = useState(false)
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null)
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as WebhookEvent[], secret: '', enabled: true })
const [deleteWebhookModal, setDeleteWebhookModal] = useState<string | null>(null)
const [savingWebhook, setSavingWebhook] = useState(false)
const handleCreateKey = async () => {
if (!newKeyName.trim()) return
try {
const result = await createAPIKey(newKeyName)
setCreatedKey(result.key)
setNewKeyName('')
setShowCreateKey(false)
addToast('API key created', 'success')
} catch {
addToast('Failed to create API key', 'error')
}
}
const handleDeleteKey = async () => {
if (!deleteKeyModal) return
try {
await deleteKey.mutate(deleteKeyModal)
addToast('API key deleted', 'success')
setDeleteKeyModal(null)
} catch {
addToast('Failed to delete API key', 'error')
}
}
const copyKey = (key: string) => {
navigator.clipboard.writeText(key)
addToast('Copied to clipboard', 'success')
}
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Never'
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const openWebhookModal = (webhook?: Webhook) => {
if (webhook) {
setEditingWebhook(webhook)
setWebhookForm({ name: webhook.name, url: webhook.url, events: webhook.events, secret: webhook.secret || '', enabled: webhook.enabled })
} else {
setEditingWebhook(null)
setWebhookForm({ name: '', url: '', events: [], secret: '', enabled: true })
}
setShowWebhookModal(true)
}
const handleSaveWebhook = async () => {
if (!webhookForm.name || !webhookForm.url || webhookForm.events.length === 0) {
addToast('Name, URL, and at least one event are required', 'error')
return
}
setSavingWebhook(true)
try {
if (editingWebhook) {
await updateWebhook(editingWebhook.id, webhookForm)
addToast('Webhook updated', 'success')
} else {
await createWebhook(webhookForm)
addToast('Webhook created', 'success')
}
setShowWebhookModal(false)
} catch {
addToast('Failed to save webhook', 'error')
} finally {
setSavingWebhook(false)
}
}
const handleDeleteWebhook = async () => {
if (!deleteWebhookModal) return
try {
await deleteWebhookMutation.mutate(deleteWebhookModal)
addToast('Webhook deleted', 'success')
setDeleteWebhookModal(null)
} catch {
addToast('Failed to delete webhook', 'error')
}
}
const handleTestWebhook = async (id: string) => {
try {
await testWebhook(id)
addToast('Test webhook sent', 'success')
} catch {
addToast('Failed to send test webhook', 'error')
}
}
const toggleEvent = (event: WebhookEvent) => {
setWebhookForm(f => ({
...f,
events: f.events.includes(event) ? f.events.filter(e => e !== event) : [...f.events, event]
}))
}
if (!keys) return <APIPageSkeleton />
const baseUrl = window.location.origin
const firstKey = keys.length > 0 ? keys[0].key : undefined
return (
<div>
<PageHeader>
<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>
Create Key
</Button>
</PageHeader>
<div className="-mx-6 lg:-mx-10 mt-6">
{createdKey && (
<div className="px-6 lg:px-10 py-4 bg-success/10 border-b border-success">
<div className="flex items-center gap-2 mb-2">
<Icons.CheckCircle className="text-success" />
<span className="text-sm font-medium">API Key Created</span>
</div>
<p className="text-xs text-muted mb-3">Copy this key now. You won't be able to see it again.</p>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-surface border border-border text-xs font-mono truncate">{createdKey}</code>
<Button variant="secondary" Icon={Icons.Copy} onClick={() => copyKey(createdKey)}>Copy</Button>
</div>
<button onClick={() => setCreatedKey(null)} className="mt-2 text-xs text-muted hover:text-text">Dismiss</button>
</div>
)}
{/* API Keys */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Your API Keys</div>
<div className="text-xs text-muted mt-0.5">Use these keys to authenticate API requests</div>
</div>
<div className="border-t border-border">
{keys.length === 0 ? (
<div className="px-6 lg:px-10 py-12">
<EmptyState
Icon={Icons.ApiKeys}
title="No API keys"
description="Create an API key to access the API"
action={<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>Create Key</Button>}
/>
</div>
) : (
keys.map((key, i) => (
<div key={key.key} className={`flex flex-col sm:flex-row sm:items-center gap-4 px-6 lg:px-10 py-4 ${i > 0 ? 'border-t border-border' : ''}`}>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{key.name}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted mt-1">
<code className="font-mono">{key.key.slice(0, 8)}...{key.key.slice(-4)}</code>
<span className="hidden sm:inline"></span>
<span>Created {formatDate(key.created_at)}</span>
<span className="hidden sm:inline"></span>
<span>Last used {formatDate(key.last_used_at)}</span>
</div>
</div>
<Button variant="ghost" Icon={Icons.Trash} className="text-danger hover:text-danger self-start sm:self-center" onClick={() => setDeleteKeyModal(key.key)}>Delete</Button>
</div>
))
)}
</div>
<div className="border-t border-border" />
{/* Webhooks */}
<div className="px-6 lg:px-10 py-5">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-muted uppercase tracking-wide">Webhooks</div>
<div className="text-xs text-muted mt-0.5">Get notified when posts are published, updated, or deleted</div>
</div>
{billing && (
<UsageIndicator
used={billing.usage.webhooks}
max={billing.tiers[billing.current_tier].max_webhooks}
label="used"
/>
)}
</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-3">
{webhooks && webhooks.length > 0 ? (
webhooks.map(webhook => (
<WebhookCard
key={webhook.id}
webhook={webhook}
onEdit={() => openWebhookModal(webhook)}
onDelete={() => setDeleteWebhookModal(webhook.id)}
onTest={() => handleTestWebhook(webhook.id)}
/>
))
) : (
<div className="text-sm text-muted">No webhooks configured</div>
)}
{billing && billing.usage.webhooks >= billing.tiers[billing.current_tier].max_webhooks ? (
<div className="flex items-center gap-3 p-3 border border-warning/30 bg-warning/5">
<Icons.AlertCircle className="text-warning flex-shrink-0" />
<span className="text-sm text-muted">Webhook limit reached.</span>
<Button variant="primary" href="/studio/billing">Upgrade</Button>
</div>
) : (
<Button variant="secondary" Icon={Icons.Plus} onClick={() => openWebhookModal()}>Add Webhook</Button>
)}
</div>
<div className="border-t border-border" />
{/* API Reference */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">API Reference</div>
<div className="text-xs text-muted mt-0.5">Base URL: <code className="text-text">{baseUrl}</code></div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-2">
{endpoints.map((endpoint) => (
<EndpointCard key={`${endpoint.method}-${endpoint.path}`} endpoint={endpoint} baseUrl={baseUrl} apiKey={firstKey} />
))}
</div>
<div className="border-t border-border" />
{/* Authentication */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Authentication</div>
<div className="text-xs text-muted mt-0.5">How to authenticate your requests</div>
</div>
<div className="px-6 lg:px-10 py-6">
<p className="text-sm text-muted mb-3">All API requests require a Bearer token in the Authorization header:</p>
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
Authorization: Bearer {firstKey ? `${firstKey.slice(0, 8)}...` : 'YOUR_API_KEY'}
</pre>
<p className="text-xs text-muted mt-3">Keep API keys secure and never expose them in client-side code.</p>
</div>
</div>
{/* Create Key Modal */}
<Modal open={showCreateKey} onClose={() => setShowCreateKey(false)} title="Create API Key">
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Key Name</label>
<Input value={newKeyName} onChange={setNewKeyName} placeholder="My API Key" />
<p className="text-xs text-muted">A name to identify this key</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowCreateKey(false)}>Cancel</Button>
<Button variant="primary" onClick={handleCreateKey} loading={creating} disabled={!newKeyName.trim()}>Create</Button>
</div>
</div>
</Modal>
{/* Delete Key Modal */}
<Modal open={!!deleteKeyModal} onClose={() => setDeleteKeyModal(null)} title="Delete API Key">
<p className="text-sm text-muted mb-4">Are you sure? Applications using this key will stop working.</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteKeyModal(null)}>Cancel</Button>
<Button variant="danger" onClick={handleDeleteKey} loading={deleteKey.loading}>Delete</Button>
</div>
</Modal>
{/* Webhook Modal */}
<Modal open={showWebhookModal} onClose={() => setShowWebhookModal(false)} title={editingWebhook ? 'Edit Webhook' : 'Add Webhook'}>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Name</label>
<Input value={webhookForm.name} onChange={v => setWebhookForm(f => ({ ...f, name: v }))} placeholder="Discord Notification" />
</div>
<div className="space-y-1">
<label className="label">URL</label>
<Input value={webhookForm.url} onChange={v => setWebhookForm(f => ({ ...f, url: v }))} placeholder="https://your-server.com/webhook" />
</div>
<div className="space-y-2">
<label className="label">Events</label>
<div className="flex flex-wrap gap-2">
{webhookEvents.map(e => (
<button
key={e.value}
onClick={() => toggleEvent(e.value)}
className={`px-3 py-1.5 text-xs border transition-colors ${webhookForm.events.includes(e.value) ? 'border-accent bg-accent/10 text-accent' : 'border-border text-muted hover:border-muted'}`}
>
{e.label}
</button>
))}
</div>
</div>
<div className="space-y-1">
<label className="label">Secret (optional)</label>
<Input value={webhookForm.secret} onChange={v => setWebhookForm(f => ({ ...f, secret: v }))} placeholder="For HMAC signature verification" />
<p className="text-xs text-muted">Used to sign payloads with X-WriteKit-Signature header</p>
</div>
{editingWebhook && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={webhookForm.enabled} onChange={e => setWebhookForm(f => ({ ...f, enabled: e.target.checked }))} />
Enabled
</label>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowWebhookModal(false)}>Cancel</Button>
<Button variant="primary" onClick={handleSaveWebhook} loading={savingWebhook}>{editingWebhook ? 'Save' : 'Create'}</Button>
</div>
</div>
</Modal>
{/* Delete Webhook Modal */}
<Modal open={!!deleteWebhookModal} onClose={() => setDeleteWebhookModal(null)} title="Delete Webhook">
<p className="text-sm text-muted mb-4">Are you sure you want to delete this webhook?</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteWebhookModal(null)}>Cancel</Button>
<Button variant="danger" onClick={handleDeleteWebhook} loading={deleteWebhookMutation.loading}>Delete</Button>
</div>
</Modal>
</div>
)
}