Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

style: ui improvements

Hugo 475a5bfa 066250eb

+236 -39
+62 -3
app/islands/RecordFormBuilder.tsx
··· 14 14 loading: boolean; 15 15 error: string; 16 16 placeholders: string[]; 17 + initialTemplate?: string; 17 18 onChange: (jsonTemplate: string) => void; 18 19 }; 19 20 ··· 34 35 state[key] = String(node.default); 35 36 } else if (node.type === "boolean" && node.default != null) { 36 37 state[key] = String(node.default); 38 + } 39 + } 40 + return state; 41 + } 42 + 43 + /** Reconstruct form state from a saved JSON template string. */ 44 + function deserializeState( 45 + template: string, 46 + properties: Record<string, SchemaNode>, 47 + ): FormState | null { 48 + try { 49 + const obj = JSON.parse(template); 50 + if (typeof obj !== "object" || obj === null) return null; 51 + return deserializeObject(obj as Record<string, unknown>, properties); 52 + } catch { 53 + return null; 54 + } 55 + } 56 + 57 + function deserializeObject( 58 + obj: Record<string, unknown>, 59 + properties: Record<string, SchemaNode>, 60 + ): FormState { 61 + const state: FormState = {}; 62 + for (const [key, node] of Object.entries(properties)) { 63 + const value = obj[key]; 64 + if (value === undefined) continue; 65 + switch (node.type) { 66 + case "string": 67 + state[key] = typeof value === "string" ? value : String(value); 68 + break; 69 + case "integer": 70 + state[key] = String(value); 71 + break; 72 + case "boolean": 73 + state[key] = String(value); 74 + break; 75 + case "object": 76 + if (typeof value === "object" && value !== null) { 77 + state[key] = deserializeObject(value as Record<string, unknown>, node.properties); 78 + } 79 + break; 80 + case "array": 81 + if (Array.isArray(value)) { 82 + state[key] = value.map((item) => { 83 + if (node.items.type === "object" && typeof item === "object" && item !== null) { 84 + return deserializeObject(item as Record<string, unknown>, node.items.properties); 85 + } 86 + return typeof item === "string" ? item : JSON.stringify(item); 87 + }); 88 + } 89 + break; 90 + case "unknown": 91 + state[key] = typeof value === "string" ? value : JSON.stringify(value); 92 + break; 37 93 } 38 94 } 39 95 return state; ··· 527 583 loading, 528 584 error, 529 585 placeholders, 586 + initialTemplate, 530 587 onChange, 531 588 }: Props) { 532 589 const [state, setState] = useState<FormState>({}); 533 590 534 591 useEffect(() => { 535 592 if (schema) { 536 - const initial = initState(schema.properties); 537 - setState(initial); 538 - onChange(serialize(schema, initial)); 593 + const restored = 594 + initialTemplate != null ? deserializeState(initialTemplate, schema.properties) : null; 595 + const formState = restored ?? initState(schema.properties); 596 + setState(formState); 597 + onChange(serialize(schema, formState)); 539 598 } 540 599 }, [schema]); 541 600
+9
app/islands/SubscriptionForm.css.ts
··· 185 185 export const resetFieldset = style({ 186 186 border: "none", 187 187 padding: 0, 188 + minInlineSize: 0, 188 189 }); 189 190 190 191 export const textarea = style({ ··· 260 261 export const collapsibleDetails = style({ 261 262 border: `1px solid ${vars.color.border}`, 262 263 borderRadius: radii.md, 264 + minWidth: 0, 263 265 }); 264 266 265 267 export const collapsibleSummary = style({ ··· 303 305 display: "flex", 304 306 flexDirection: "column", 305 307 gap: space[3], 308 + selectors: { 309 + "pre&": { 310 + display: "block", 311 + wordBreak: "normal", 312 + overflowX: "auto", 313 + }, 314 + }, 306 315 }); 307 316 308 317 export const placeholderGroup = style({
+86 -25
app/islands/SubscriptionForm.tsx
··· 1 1 import { useState, useCallback, useRef, useMemo } from "hono/jsx"; 2 2 import type { RecordSchema } from "../../lib/lexicons/schema-tree.js"; 3 + import type { Action, FetchStep } from "../../lib/db/schema.js"; 3 4 import RecordFormBuilder from "./RecordFormBuilder.js"; 4 5 import * as s from "./SubscriptionForm.css.ts"; 5 6 ··· 26 27 comment: string; 27 28 }; 28 29 type ActionDraft = WebhookDraft | RecordDraft; 30 + 31 + export type SubscriptionInitial = { 32 + rkey: string; 33 + name: string; 34 + description: string | null; 35 + lexicon: string; 36 + actions: Action[]; 37 + fetches: FetchStep[]; 38 + conditions: Array<{ field: string; operator: string; value: string; comment?: string }>; 39 + active: boolean; 40 + }; 29 41 30 42 const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; 31 43 ··· 197 209 loading={targetSchemaLoading} 198 210 error={targetSchemaError} 199 211 placeholders={placeholders} 212 + initialTemplate={action.recordTemplate || undefined} 200 213 onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 201 214 /> 202 215 )} ··· 260 273 // Main form 261 274 // --------------------------------------------------------------------------- 262 275 263 - export default function SubscriptionForm() { 264 - const initial = getInitialParam("lexicon"); 265 - const [name, setName] = useState(""); 266 - const [description, setDescription] = useState(""); 267 - const [lexicon, setLexicon] = useState(initial); 276 + function toActionDrafts(actions: Action[]): ActionDraft[] { 277 + return actions.map((a) => { 278 + if (a.$type === "webhook") { 279 + return { type: "webhook", callbackUrl: a.callbackUrl, comment: a.comment ?? "" }; 280 + } 281 + return { 282 + type: "record", 283 + targetCollection: a.targetCollection, 284 + recordTemplate: a.recordTemplate, 285 + comment: a.comment ?? "", 286 + }; 287 + }); 288 + } 289 + 290 + function toFetchDrafts(fetches: FetchStep[]): FetchDraft[] { 291 + return fetches.map((f) => ({ name: f.name, uri: f.uri, comment: f.comment ?? "" })); 292 + } 293 + 294 + function toConditionDrafts( 295 + conditions: Array<{ field: string; operator: string; value: string; comment?: string }>, 296 + ): Condition[] { 297 + return conditions.map((c) => ({ ...c, comment: c.comment ?? "" })); 298 + } 299 + 300 + export default function SubscriptionForm({ initial }: { initial?: SubscriptionInitial }) { 301 + const isEdit = !!initial; 302 + const initialLexicon = initial?.lexicon ?? getInitialParam("lexicon"); 303 + const [name, setName] = useState(initial?.name ?? ""); 304 + const [description, setDescription] = useState(initial?.description ?? ""); 305 + const [lexicon, setLexicon] = useState(initialLexicon); 268 306 const [fields, setFields] = useState<Field[]>([]); 269 307 const [fieldsLoading, setFieldsLoading] = useState(false); 270 308 const [fieldsError, setFieldsError] = useState(""); 271 - const [conditions, setConditions] = useState<Condition[]>([]); 272 - const [fetches, setFetches] = useState<FetchDraft[]>([]); 273 - const [actions, setActions] = useState<ActionDraft[]>([]); 309 + const [conditions, setConditions] = useState<Condition[]>( 310 + initial ? toConditionDrafts(initial.conditions) : [], 311 + ); 312 + const [fetches, setFetches] = useState<FetchDraft[]>( 313 + initial ? toFetchDrafts(initial.fetches) : [], 314 + ); 315 + const [actions, setActions] = useState<ActionDraft[]>( 316 + initial ? toActionDrafts(initial.actions) : [], 317 + ); 274 318 const [nsidSuggestions, setNsidSuggestions] = useState<string[]>([]); 275 319 const [submitting, setSubmitting] = useState(false); 276 320 const [error, setError] = useState(""); ··· 281 325 282 326 const fetchFields = useCallback((nsid: string, updateUrl = true) => { 283 327 if (debounceRef.current) clearTimeout(debounceRef.current); 284 - setConditions([]); 285 328 if (!nsid) { 286 329 setFields([]); 287 330 setFieldsError(""); ··· 343 386 }, 300); 344 387 }, []); 345 388 346 - if (!initialFetched.current && initial) { 389 + if (!initialFetched.current && initialLexicon) { 347 390 initialFetched.current = true; 348 - fetchFields(initial, false); 391 + fetchFields(initialLexicon, false); 349 392 } 350 393 351 394 const addCondition = useCallback(() => { ··· 433 476 setError(""); 434 477 setSubmitting(true); 435 478 try { 436 - const res = await fetch("/api/subscriptions", { 437 - method: "POST", 479 + const url = isEdit ? `/api/subscriptions/${initial.rkey}` : "/api/subscriptions"; 480 + const res = await fetch(url, { 481 + method: isEdit ? "PATCH" : "POST", 438 482 headers: { "Content-Type": "application/json" }, 439 483 body: previewPayload, 440 484 }); 441 485 const data = await res.json(); 442 486 if (!res.ok) { 443 - setError(data.error || "Failed to create subscription"); 487 + setError(data.error || `Failed to ${isEdit ? "update" : "create"} subscription`); 444 488 } else { 445 - window.location.href = `/dashboard/subscriptions/${data.rkey}`; 489 + const rkey = isEdit ? initial.rkey : data.rkey; 490 + window.location.href = `/dashboard/subscriptions/${rkey}`; 446 491 } 447 492 } catch { 448 493 setError("Request failed"); ··· 450 495 setSubmitting(false); 451 496 } 452 497 }, 453 - [previewPayload], 498 + [previewPayload, isEdit], 454 499 ); 455 500 456 501 const allPlaceholders = [ ··· 504 549 id="lexicon" 505 550 class={s.input} 506 551 type="text" 507 - list="nsid-suggestions" 552 + list={isEdit ? undefined : "nsid-suggestions"} 508 553 placeholder="e.g. sh.tangled.feed.star" 509 554 value={lexicon} 510 - onInput={(e: Event) => { 511 - const val = (e.target as HTMLInputElement).value; 512 - setLexicon(val); 513 - fetchFields(val); 514 - fetchSuggestions(val); 515 - }} 555 + onInput={ 556 + isEdit 557 + ? undefined 558 + : (e: Event) => { 559 + const val = (e.target as HTMLInputElement).value; 560 + setLexicon(val); 561 + setConditions([]); 562 + fetchFields(val); 563 + fetchSuggestions(val); 564 + } 565 + } 566 + readOnly={isEdit} 516 567 required 517 568 /> 569 + {isEdit && <span class={s.hint}>Lexicon cannot be changed after creation</span>} 518 570 {fieldsLoading && <span class={s.hint}>Loading fields...</span>} 519 571 {fieldsError && <span class={s.errorText}>{fieldsError}</span>} 520 572 </div> ··· 597 649 <select 598 650 class={s.select} 599 651 value={cond.field} 652 + disabled={fieldsLoading} 600 653 onChange={(e: Event) => 601 654 updateCondition(i, "field", (e.target as HTMLSelectElement).value) 602 655 } 603 656 > 604 - <option value="">Select field...</option> 657 + <option value=""> 658 + {fieldsLoading ? "Loading fields..." : "Select field..."} 659 + </option> 605 660 {conditionFields.map((f) => ( 606 661 <option key={f.path} value={f.path}> 607 662 {f.path} ··· 794 849 {error && <div class={s.alertError}>{error}</div>} 795 850 796 851 <button type="submit" class={s.submitBtn} disabled={!name.trim() || actions.length === 0}> 797 - {submitting ? "Creating..." : "Create subscription"} 852 + {submitting 853 + ? isEdit 854 + ? "Updating..." 855 + : "Creating..." 856 + : isEdit 857 + ? "Update subscription" 858 + : "Create subscription"} 798 859 </button> 799 860 800 861 <datalist id="nsid-suggestions">
+3
app/routes/dashboard/subscriptions/[rkey].tsx
··· 60 60 <Badge variant={sub.active ? "success" : "neutral"}> 61 61 {sub.active ? "Active" : "Inactive"} 62 62 </Badge> 63 + <Button href={`/dashboard/subscriptions/${rkey}/edit`} variant="secondary" size="sm"> 64 + Edit 65 + </Button> 63 66 <Button href="/dashboard" variant="ghost" size="sm"> 64 67 &larr; Back 65 68 </Button>
+71
app/routes/dashboard/subscriptions/[rkey]/edit.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq, and } from "drizzle-orm"; 3 + import { db } from "@/db/index.js"; 4 + import { subscriptions } from "@/db/schema.js"; 5 + import { AppShell } from "../../../../components/Layout/AppShell/index.js"; 6 + import { Header } from "../../../../components/Layout/Header/index.js"; 7 + import { Container } from "../../../../components/Layout/Container/index.js"; 8 + import { PageHeader } from "../../../../components/Layout/PageHeader/index.js"; 9 + import { Card } from "../../../../components/Card/index.js"; 10 + import { Button } from "../../../../components/Button/index.js"; 11 + import ThemeToggle from "../../../../islands/ThemeToggle.js"; 12 + import SubscriptionForm from "../../../../islands/SubscriptionForm.js"; 13 + 14 + export default createRoute(async (c) => { 15 + const user = c.get("user"); 16 + const rkey = c.req.param("rkey")!; 17 + 18 + const sub = await db.query.subscriptions.findFirst({ 19 + where: and(eq(subscriptions.did, user.did), eq(subscriptions.rkey, rkey)), 20 + }); 21 + 22 + if (!sub) { 23 + c.status(404); 24 + return c.render( 25 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 26 + <Container> 27 + <PageHeader 28 + title="Not Found" 29 + actions={ 30 + <Button href="/dashboard" variant="ghost" size="sm"> 31 + &larr; Back 32 + </Button> 33 + } 34 + /> 35 + <p>This subscription does not exist.</p> 36 + </Container> 37 + </AppShell>, 38 + { title: "Not Found — Airglow" }, 39 + ); 40 + } 41 + 42 + return c.render( 43 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 44 + <Container> 45 + <PageHeader 46 + title={`Edit: ${sub.name}`} 47 + actions={ 48 + <Button href={`/dashboard/subscriptions/${rkey}`} variant="ghost" size="sm"> 49 + &larr; Back 50 + </Button> 51 + } 52 + /> 53 + <Card variant="flat"> 54 + <SubscriptionForm 55 + initial={{ 56 + rkey: sub.rkey, 57 + name: sub.name, 58 + description: sub.description, 59 + lexicon: sub.lexicon, 60 + actions: sub.actions, 61 + fetches: sub.fetches, 62 + conditions: sub.conditions, 63 + active: sub.active, 64 + }} 65 + /> 66 + </Card> 67 + </Container> 68 + </AppShell>, 69 + { title: `Edit ${sub.name} — Airglow` }, 70 + ); 71 + });
+4 -10
lib/db/migrations/meta/0000_snapshot.json
··· 79 79 "name": "delivery_logs_subscription_uri_subscriptions_uri_fk", 80 80 "tableFrom": "delivery_logs", 81 81 "tableTo": "subscriptions", 82 - "columnsFrom": [ 83 - "subscription_uri" 84 - ], 85 - "columnsTo": [ 86 - "uri" 87 - ], 82 + "columnsFrom": ["subscription_uri"], 83 + "columnsTo": ["uri"], 88 84 "onDelete": "cascade", 89 85 "onUpdate": "no action" 90 86 } ··· 312 308 "indexes": { 313 309 "users_did_unique": { 314 310 "name": "users_did_unique", 315 - "columns": [ 316 - "did" 317 - ], 311 + "columns": ["did"], 318 312 "isUnique": true 319 313 } 320 314 }, ··· 334 328 "internal": { 335 329 "indexes": {} 336 330 } 337 - } 331 + }
+1 -1
lib/db/migrations/meta/_journal.json
··· 10 10 "breakpoints": true 11 11 } 12 12 ] 13 - } 13 + }