Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

styling work

+203 -82
+2 -1
apps/main-app/public/editor/editor.tsx
··· 532 532 eventLogs={eventLogs} 533 533 eventLogsLoading={eventLogsLoading} 534 534 isCreating={isCreating} 535 - onCreateWebhook={createWebhook} 535 + userDid={userInfo?.did} 536 + onCreateWebhook={createWebhook} 536 537 onDeleteWebhook={deleteWebhook} 537 538 onRefreshEvents={fetchEventLogs} 538 539 />
+201 -81
apps/main-app/public/editor/tabs/WebhooksTab.tsx
··· 8 8 import { Loader2, RefreshCw, Trash2 } from 'lucide-react' 9 9 import type { WebhookRecord, WebhookEventLog } from '../hooks/useWebhookData' 10 10 11 + const APPS = [ 12 + { id: 'bluesky', label: 'Bluesky', path: 'app.bsky.*' }, 13 + { id: 'tangled', label: 'Tangled', path: 'chat.tangled.*' }, 14 + { id: 'leaflet', label: 'Leaflet', path: 'pub.leaflet.*' }, 15 + { id: 'wisp', label: 'wisp', path: 'place.wisp.*' }, 16 + { id: 'blento', label: 'Blento', path: 'blue.blento.*' }, 17 + ] as const 18 + 19 + type AppId = typeof APPS[number]['id'] | 'other' 20 + type OtherMode = 'all' | 'collection' | 'rkey' 21 + 11 22 interface WebhooksTabProps { 12 23 webhooks: WebhookRecord[] 13 24 webhooksLoading: boolean 14 25 eventLogs: WebhookEventLog[] 15 26 eventLogsLoading: boolean 16 27 isCreating: boolean 28 + userDid?: string 17 29 onCreateWebhook: (data: { 18 30 scopeAturi: string 19 31 url: string ··· 26 38 onRefreshEvents: () => Promise<void> 27 39 } 28 40 41 + function buildScope(userDid: string, selectedApp: AppId | null, scopePath: string, otherMode: OtherMode, otherCollection: string, otherRkey: string): string { 42 + if (!userDid) return '' 43 + if (!selectedApp) return '' 44 + if (selectedApp === 'other') { 45 + if (otherMode === 'all') return `at://${userDid}` 46 + if (otherMode === 'collection') return otherCollection ? `at://${userDid}/${otherCollection}` : '' 47 + return (otherCollection && otherRkey) ? `at://${userDid}/${otherCollection}/${otherRkey}` : '' 48 + } 49 + return scopePath ? `at://${userDid}/${scopePath}` : `at://${userDid}` 50 + } 51 + 29 52 export function WebhooksTab({ 30 53 webhooks, 31 54 webhooksLoading, 32 55 eventLogs, 33 56 eventLogsLoading, 34 57 isCreating, 58 + userDid = '', 35 59 onCreateWebhook, 36 60 onDeleteWebhook, 37 61 onRefreshEvents, 38 62 }: WebhooksTabProps) { 39 63 const [url, setUrl] = useState('') 40 - const [scopeAturi, setScopeAturi] = useState('') 64 + const [selectedApp, setSelectedApp] = useState<AppId | null>(null) 65 + const [scopePath, setScopePath] = useState('') 66 + const [otherMode, setOtherMode] = useState<OtherMode>('all') 67 + const [otherCollection, setOtherCollection] = useState('') 68 + const [otherRkey, setOtherRkey] = useState('') 41 69 const [backlinks, setBacklinks] = useState(false) 42 70 const [eventCreate, setEventCreate] = useState(true) 43 71 const [eventUpdate, setEventUpdate] = useState(true) 44 72 const [eventDelete, setEventDelete] = useState(true) 45 - const [secret, setSecret] = useState('') 46 - const [enabled, setEnabled] = useState(true) 47 73 const [error, setError] = useState<string | null>(null) 48 74 const [success, setSuccess] = useState<string | null>(null) 49 75 const [deletingRkey, setDeletingRkey] = useState<string | null>(null) 50 76 51 - // Auto-refresh event logs every 60 seconds 52 77 useEffect(() => { 53 78 const id = setInterval(onRefreshEvents, 60_000) 54 79 return () => clearInterval(id) 55 80 }, [onRefreshEvents]) 56 81 82 + const selectApp = (id: AppId) => { 83 + setSelectedApp(id) 84 + if (id !== 'other') { 85 + const app = APPS.find(a => a.id === id) 86 + setScopePath(app?.path ?? '') 87 + } 88 + setError(null) 89 + } 90 + 91 + const scopeAturi = buildScope(userDid, selectedApp, scopePath, otherMode, otherCollection, otherRkey) 92 + 57 93 const handleCreate = async (e: React.FormEvent) => { 58 94 e.preventDefault() 59 95 setError(null) ··· 63 99 setError('URL must start with http:// or https://') 64 100 return 65 101 } 66 - if (!scopeAturi.startsWith('at://')) { 67 - setError('Scope must be a valid AT-URI (at://...)') 102 + if (!scopeAturi || !scopeAturi.startsWith('at://')) { 103 + setError('Please select an app scope above') 68 104 return 69 105 } 70 106 ··· 74 110 if (eventDelete) events.push('delete') 75 111 76 112 try { 77 - await onCreateWebhook({ url, scopeAturi, backlinks, events: events.length === 3 ? [] : events, secret, enabled }) 78 - setSuccess('Webhook created. It will become active once the service picks it up from the firehose.') 113 + await onCreateWebhook({ url, scopeAturi, backlinks, events: events.length === 3 ? [] : events, secret: '', enabled: true }) 114 + setSuccess('Webhook created.') 79 115 setUrl('') 80 - setScopeAturi('') 116 + setSelectedApp(null) 117 + setScopePath('') 118 + setOtherMode('all') 119 + setOtherCollection('') 120 + setOtherRkey('') 81 121 setBacklinks(false) 82 122 setEventCreate(true) 83 123 setEventUpdate(true) 84 124 setEventDelete(true) 85 - setSecret('') 86 - setEnabled(true) 87 125 } catch (err) { 88 126 setError(err instanceof Error ? err.message : 'Failed to create webhook') 89 127 } ··· 108 146 <p className="text-xs text-muted-foreground mt-0.5">Receive HTTP callbacks when AT Protocol records change</p> 109 147 </div> 110 148 111 - {/* Content */} 112 149 <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-6"> 113 150 114 - {/* Create webhook form */} 115 - <div className="space-y-3"> 151 + {/* Create form */} 152 + <form onSubmit={handleCreate} className="space-y-4"> 116 153 <p className="text-xs uppercase tracking-wider text-muted-foreground">Create Webhook</p> 117 - <form onSubmit={handleCreate} className="space-y-3 p-3 border border-border/30"> 118 - <div className="space-y-1"> 119 - <Label htmlFor="wh-url" className="text-xs">URL <span className="text-muted-foreground">(required)</span></Label> 120 - <Input 121 - id="wh-url" 122 - value={url} 123 - onChange={e => setUrl(e.target.value)} 124 - placeholder="https://example.com/webhook" 125 - required 126 - className="h-8 text-sm" 127 - /> 154 + 155 + {/* URL */} 156 + <div className="space-y-1"> 157 + <Label htmlFor="wh-url" className="text-xs text-muted-foreground">URL</Label> 158 + <Input 159 + id="wh-url" 160 + value={url} 161 + onChange={e => setUrl(e.target.value)} 162 + placeholder="https://example.com/webhook" 163 + required 164 + className="h-8 text-sm" 165 + /> 166 + </div> 167 + 168 + {/* App picker */} 169 + <div className="space-y-2"> 170 + <Label className="text-xs text-muted-foreground">App</Label> 171 + <div className="flex flex-wrap gap-1.5"> 172 + {APPS.map(app => ( 173 + <button 174 + key={app.id} 175 + type="button" 176 + onClick={() => selectApp(app.id)} 177 + className={`px-3 py-1 text-xs border transition-colors ${ 178 + selectedApp === app.id 179 + ? 'border-accent bg-accent/20 text-foreground' 180 + : 'border-border/40 text-muted-foreground hover:border-border hover:text-foreground' 181 + }`} 182 + > 183 + {app.label} 184 + </button> 185 + ))} 186 + <button 187 + type="button" 188 + onClick={() => selectApp('other')} 189 + className={`px-3 py-1 text-xs border transition-colors ${ 190 + selectedApp === 'other' 191 + ? 'border-accent bg-accent/20 text-foreground' 192 + : 'border-border/40 text-muted-foreground hover:border-border hover:text-foreground' 193 + }`} 194 + > 195 + Other 196 + </button> 128 197 </div> 198 + </div> 129 199 200 + {/* Scope detail — known app */} 201 + {selectedApp && selectedApp !== 'other' && ( 130 202 <div className="space-y-1"> 131 - <Label htmlFor="wh-scope" className="text-xs">Scope <span className="text-muted-foreground">(required)</span></Label> 132 - <Input 133 - id="wh-scope" 134 - value={scopeAturi} 135 - onChange={e => setScopeAturi(e.target.value)} 136 - placeholder="at://did:plc:... or at://did:plc:.../app.bsky.*" 137 - required 138 - className="h-8 text-sm" 139 - /> 140 - <p className="text-xs text-muted-foreground"> 141 - <code className="bg-muted px-1">at://did</code> watches all records,{' '} 142 - <code className="bg-muted px-1">at://did/collection</code> for a specific one,{' '} 143 - <code className="bg-muted px-1">at://did/app.bsky.*</code> for a glob. 144 - </p> 203 + <Label htmlFor="wh-path" className="text-xs text-muted-foreground">Collection / glob</Label> 204 + <div className="flex items-center gap-0 border border-border/40 focus-within:border-border"> 205 + <span className="px-2 py-1.5 text-xs text-muted-foreground bg-muted/40 border-r border-border/40 whitespace-nowrap select-none"> 206 + at://{userDid || 'did'}/ 207 + </span> 208 + <input 209 + id="wh-path" 210 + value={scopePath} 211 + onChange={e => setScopePath(e.target.value)} 212 + placeholder="app.bsky.*" 213 + className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none font-mono" 214 + /> 215 + </div> 145 216 </div> 217 + )} 146 218 219 + {/* Scope detail — other */} 220 + {selectedApp === 'other' && ( 221 + <div className="space-y-2"> 222 + <Label className="text-xs text-muted-foreground">Scope</Label> 223 + <div className="space-y-1.5"> 224 + {([ 225 + ['all', 'All my records', `at://${userDid || 'did'}`], 226 + ['collection', 'Specific collection', ''], 227 + ['rkey', 'Specific record', ''], 228 + ] as const).map(([mode, label, hint]) => ( 229 + <label key={mode} className="flex items-start gap-2 cursor-pointer group"> 230 + <input 231 + type="radio" 232 + name="other-mode" 233 + checked={otherMode === mode} 234 + onChange={() => setOtherMode(mode)} 235 + className="mt-0.5 accent-accent" 236 + /> 237 + <div className="flex-1"> 238 + <span className="text-xs">{label}</span> 239 + {hint && <span className="text-xs text-muted-foreground ml-2">{hint}</span>} 240 + </div> 241 + </label> 242 + ))} 243 + </div> 244 + {otherMode === 'collection' && ( 245 + <Input 246 + value={otherCollection} 247 + onChange={e => setOtherCollection(e.target.value)} 248 + placeholder="app.bsky.feed.post" 249 + className="h-8 text-xs" 250 + /> 251 + )} 252 + {otherMode === 'rkey' && ( 253 + <div className="flex gap-2"> 254 + <Input 255 + value={otherCollection} 256 + onChange={e => setOtherCollection(e.target.value)} 257 + placeholder="collection" 258 + className="h-8 text-xs flex-1" 259 + /> 260 + <Input 261 + value={otherRkey} 262 + onChange={e => setOtherRkey(e.target.value)} 263 + placeholder="rkey" 264 + className="h-8 text-xs flex-1" 265 + /> 266 + </div> 267 + )} 268 + </div> 269 + )} 270 + 271 + {/* Wildcard hint */} 272 + {scopeAturi.includes('*') && ( 273 + <p className="text-xs text-muted-foreground"> 274 + <code className="bg-muted px-1">*</code> is a wildcard — matches any collection name at that level. 275 + </p> 276 + )} 277 + 278 + {/* Backlinks */} 279 + {selectedApp && ( 147 280 <div className="flex items-center gap-2"> 148 281 <Checkbox id="wh-backlinks" checked={backlinks} onCheckedChange={v => setBacklinks(!!v)} /> 149 282 <Label htmlFor="wh-backlinks" className="cursor-pointer text-xs"> 150 283 Backlinks <span className="text-muted-foreground">— also fire when other records reference this scope</span> 151 284 </Label> 152 285 </div> 286 + )} 153 287 288 + {/* Events */} 289 + {selectedApp && ( 154 290 <div className="space-y-1.5"> 155 - <Label className="text-xs">Events</Label> 291 + <Label className="text-xs text-muted-foreground">Events</Label> 156 292 <div className="flex gap-4"> 157 293 {([['create', eventCreate, setEventCreate], ['update', eventUpdate, setEventUpdate], ['delete', eventDelete, setEventDelete]] as const).map(([name, val, set]) => ( 158 294 <div key={name} className="flex items-center gap-1.5"> ··· 161 297 </div> 162 298 ))} 163 299 </div> 164 - <p className="text-xs text-muted-foreground">All checked = no filter (fires on all events)</p> 300 + <p className="text-xs text-muted-foreground">All checked = no filter</p> 165 301 </div> 302 + )} 166 303 167 - <div className="space-y-1"> 168 - <Label htmlFor="wh-secret" className="text-xs">Secret <span className="text-muted-foreground">(optional)</span></Label> 169 - <Input 170 - id="wh-secret" 171 - type="password" 172 - value={secret} 173 - onChange={e => setSecret(e.target.value)} 174 - placeholder="HMAC-SHA256 signing secret" 175 - className="h-8 text-sm" 176 - /> 177 - <p className="text-xs text-muted-foreground">Stored publicly in your PDS record — transport integrity, not authentication.</p> 178 - </div> 304 + {error && <p className="text-xs text-destructive">{error}</p>} 305 + {success && <p className="text-xs text-green-500">{success}</p>} 179 306 180 - <div className="flex items-center gap-2"> 181 - <Checkbox id="wh-enabled" checked={enabled} onCheckedChange={v => setEnabled(!!v)} /> 182 - <Label htmlFor="wh-enabled" className="cursor-pointer text-xs">Enabled</Label> 183 - </div> 184 - 185 - {error && <p className="text-xs text-destructive">{error}</p>} 186 - {success && <p className="text-xs text-green-500">{success}</p>} 187 - 188 - <Button type="submit" disabled={isCreating} size="sm" className="w-full sm:w-auto"> 307 + {selectedApp && ( 308 + <Button type="submit" disabled={isCreating || !scopeAturi} size="sm"> 189 309 {isCreating ? <><Loader2 className="w-3 h-3 mr-2 animate-spin" />Creating...</> : 'Create Webhook'} 190 310 </Button> 191 - </form> 192 - </div> 311 + )} 312 + </form> 193 313 194 314 {/* Existing webhooks */} 195 315 <div className="space-y-2"> 196 316 <p className="text-xs uppercase tracking-wider text-muted-foreground">Your Webhooks</p> 197 317 {webhooksLoading ? ( 198 318 <div className="space-y-2"> 199 - {[...Array(2)].map((_, i) => <SkeletonShimmer key={i} className="h-14 w-full" />)} 319 + {[...Array(2)].map((_, i) => <SkeletonShimmer key={i} className="h-12 w-full" />)} 200 320 </div> 201 321 ) : webhooks.length === 0 ? ( 202 - <p className="text-xs text-muted-foreground py-2">No webhooks configured.</p> 322 + <p className="text-xs text-muted-foreground py-1">No webhooks configured.</p> 203 323 ) : ( 204 - <div className="space-y-2"> 324 + <div className="space-y-1.5"> 205 325 {webhooks.map(wh => ( 206 326 <div key={wh.rkey} className="flex items-start justify-between p-3 border border-border/30 gap-4"> 207 - <div className="space-y-1 min-w-0"> 208 - <p className="text-sm truncate">{wh.url}</p> 327 + <div className="space-y-0.5 min-w-0"> 328 + <p className="text-xs truncate">{wh.url}</p> 209 329 <p className="text-xs text-muted-foreground truncate">{wh.scopeAturi}</p> 210 - <div className="flex gap-1.5 flex-wrap"> 330 + <div className="flex gap-1 flex-wrap mt-1"> 211 331 {!wh.enabled && <Badge variant="secondary" className="text-[10px]">disabled</Badge>} 212 332 {wh.backlinks && <Badge variant="outline" className="text-[10px]">backlinks</Badge>} 213 333 {wh.events.length > 0 && wh.events.map(e => <Badge key={e} variant="outline" className="text-[10px]">{e}</Badge>)} ··· 232 352 <div className="space-y-2"> 233 353 <div className="flex items-center justify-between"> 234 354 <p className="text-xs uppercase tracking-wider text-muted-foreground"> 235 - Event Logs <span className="font-normal normal-case">(auto-refreshes every 60s)</span> 355 + Event Logs <span className="font-normal normal-case">(60s refresh)</span> 236 356 </p> 237 357 <Button variant="outline" size="sm" onClick={onRefreshEvents} disabled={eventLogsLoading} className="h-7 px-2 gap-1.5 text-xs"> 238 358 <RefreshCw className={`w-3 h-3 ${eventLogsLoading ? 'animate-spin' : ''}`} /> ··· 245 365 {[...Array(3)].map((_, i) => <SkeletonShimmer key={i} className="h-7 w-full" />)} 246 366 </div> 247 367 ) : eventLogs.length === 0 ? ( 248 - <p className="text-xs text-muted-foreground py-2">No events yet.</p> 368 + <p className="text-xs text-muted-foreground py-1">No events yet.</p> 249 369 ) : ( 250 370 <div className="overflow-x-auto border border-border/30"> 251 371 <table className="w-full text-xs font-mono border-collapse"> 252 372 <thead> 253 373 <tr className="border-b border-border/30 text-muted-foreground bg-muted/20"> 254 - <th className="text-left py-2 px-3">Time</th> 255 - <th className="text-left py-2 px-3">Status</th> 256 - <th className="text-left py-2 px-3">Event</th> 257 - <th className="text-left py-2 px-3">Source DID</th> 258 - <th className="text-left py-2 px-3">Collection</th> 259 - <th className="text-left py-2 px-3">Rkey</th> 260 - <th className="text-left py-2 px-3">Delivered To</th> 374 + <th className="text-left py-1.5 px-3">Time</th> 375 + <th className="text-left py-1.5 px-3">Status</th> 376 + <th className="text-left py-1.5 px-3">Event</th> 377 + <th className="text-left py-1.5 px-3">Source</th> 378 + <th className="text-left py-1.5 px-3">Collection</th> 379 + <th className="text-left py-1.5 px-3">Rkey</th> 380 + <th className="text-left py-1.5 px-3">Delivered To</th> 261 381 </tr> 262 382 </thead> 263 383 <tbody> ··· 268 388 <Badge variant={log.status === 'ok' ? 'default' : 'destructive'} className="text-[10px]">{log.status}</Badge> 269 389 </td> 270 390 <td className="py-1.5 px-3">{log.eventKind}</td> 271 - <td className="py-1.5 px-3 truncate max-w-[10rem]">{log.eventDid}</td> 391 + <td className="py-1.5 px-3 truncate max-w-[8rem]">{log.eventDid}</td> 272 392 <td className="py-1.5 px-3">{log.eventCollection}</td> 273 393 <td className="py-1.5 px-3">{log.eventRkey}</td> 274 - <td className="py-1.5 px-3 truncate max-w-[10rem]">{log.url}</td> 394 + <td className="py-1.5 px-3 truncate max-w-[8rem]">{log.url}</td> 275 395 </tr> 276 396 ))} 277 397 </tbody>