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.

fix: improve search ui

Hugo 19dbc836 bab469bb

+267 -82
+190
app/islands/AutomationFilters.tsx
··· 1 + import { useEffect, useRef, useState } from "hono/jsx"; 2 + import { Input } from "../components/Input/index.js"; 3 + import { Select } from "../components/Select/index.js"; 4 + import * as s from "../styles/pages/automations.css.js"; 5 + 6 + type LexiconOption = { 7 + lexicon: string; 8 + count: number; 9 + }; 10 + 11 + type Props = { 12 + q: string; 13 + lexicon: string; 14 + lexiconOptions: LexiconOption[]; 15 + }; 16 + 17 + const DEBOUNCE_MS = 300; 18 + const BUSY_DELAY_MS = 120; 19 + const RESULTS_ID = "automation-results"; 20 + 21 + const buildDisplayUrl = (params: URLSearchParams) => { 22 + const qs = params.toString(); 23 + return qs ? `/automations?${qs}` : "/automations"; 24 + }; 25 + 26 + const paramsFromFilters = (q: string, lexicon: string) => { 27 + const params = new URLSearchParams(); 28 + const trimmed = q.trim(); 29 + if (trimmed) params.set("q", trimmed); 30 + if (lexicon) params.set("lexicon", lexicon); 31 + return params; 32 + }; 33 + 34 + const canonical = (search: string) => { 35 + const p = new URLSearchParams(search); 36 + p.sort(); 37 + return p.toString(); 38 + }; 39 + 40 + const fetchFragment = async (url: string, signal: AbortSignal) => { 41 + const res = await fetch(url, { signal, headers: { "X-Fragment": "1" } }); 42 + if (!res.ok) throw new Error(`Fragment fetch failed: ${res.status}`); 43 + return res.text(); 44 + }; 45 + 46 + const swapFragment = (html: string) => { 47 + const doc = new DOMParser().parseFromString(html, "text/html"); 48 + const next = doc.getElementById(RESULTS_ID); 49 + const current = document.getElementById(RESULTS_ID); 50 + if (next && current) current.replaceWith(next); 51 + }; 52 + 53 + export default function AutomationFilters({ q, lexicon, lexiconOptions }: Props) { 54 + const [qValue, setQValue] = useState(q); 55 + const [lexiconValue, setLexiconValue] = useState(lexicon); 56 + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 57 + const abortRef = useRef<AbortController | null>(null); 58 + const lastSubmittedQRef = useRef<string>(q.trim()); 59 + 60 + const run = async (nextParams: URLSearchParams) => { 61 + if (timerRef.current) { 62 + clearTimeout(timerRef.current); 63 + timerRef.current = null; 64 + } 65 + abortRef.current?.abort(); 66 + const ctrl = new AbortController(); 67 + abortRef.current = ctrl; 68 + 69 + const prevUrl = window.location.pathname + window.location.search; 70 + const displayUrl = buildDisplayUrl(nextParams); 71 + const urlChanged = canonical(nextParams.toString()) !== canonical(window.location.search); 72 + if (urlChanged) history.pushState(null, "", displayUrl); 73 + 74 + const busyTimer = setTimeout(() => { 75 + document.getElementById(RESULTS_ID)?.setAttribute("aria-busy", "true"); 76 + }, BUSY_DELAY_MS); 77 + 78 + try { 79 + const html = await fetchFragment(displayUrl, ctrl.signal); 80 + swapFragment(html); 81 + lastSubmittedQRef.current = (nextParams.get("q") ?? "").trim(); 82 + } catch (err) { 83 + if ((err as Error).name === "AbortError") return; 84 + if (urlChanged) history.replaceState(null, "", prevUrl); 85 + console.error("Failed to update automations:", err); 86 + } finally { 87 + clearTimeout(busyTimer); 88 + if (abortRef.current === ctrl) { 89 + abortRef.current = null; 90 + document.getElementById(RESULTS_ID)?.removeAttribute("aria-busy"); 91 + } 92 + } 93 + }; 94 + 95 + useEffect(() => { 96 + const onPopState = () => { 97 + const params = new URLSearchParams(window.location.search); 98 + const newQ = (params.get("q") ?? "").trim(); 99 + const newLex = (params.get("lexicon") ?? "").trim(); 100 + setQValue(newQ); 101 + setLexiconValue(newLex); 102 + // Keep the native select in sync — JSX-level `selected` diffing on options 103 + // doesn't reliably update a select's current value after user interaction. 104 + const selectEl = document.getElementById("lexicon") as HTMLSelectElement | null; 105 + if (selectEl) selectEl.value = newLex; 106 + void run(params); 107 + }; 108 + 109 + const onDocClick = (e: MouseEvent) => { 110 + const target = e.target as HTMLElement | null; 111 + const anchor = target?.closest("a"); 112 + if (!anchor) return; 113 + if (!anchor.closest(`#${RESULTS_ID}`)) return; 114 + const href = anchor.getAttribute("href") ?? ""; 115 + if (!href.startsWith("/automations?")) return; 116 + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; 117 + e.preventDefault(); 118 + const qs = href.slice(href.indexOf("?") + 1); 119 + void run(new URLSearchParams(qs)); 120 + }; 121 + 122 + window.addEventListener("popstate", onPopState); 123 + document.addEventListener("click", onDocClick); 124 + return () => { 125 + window.removeEventListener("popstate", onPopState); 126 + document.removeEventListener("click", onDocClick); 127 + if (timerRef.current) clearTimeout(timerRef.current); 128 + abortRef.current?.abort(); 129 + }; 130 + }, []); 131 + 132 + const onQInput = (e: Event) => { 133 + const value = (e.currentTarget as HTMLInputElement).value; 134 + setQValue(value); 135 + if (timerRef.current) clearTimeout(timerRef.current); 136 + timerRef.current = setTimeout(() => { 137 + if (value.trim() !== lastSubmittedQRef.current) { 138 + void run(paramsFromFilters(value, lexiconValue)); 139 + } 140 + }, DEBOUNCE_MS); 141 + }; 142 + 143 + // hono/jsx wires `onChange` to the native `input` event on <select>, 144 + // not `change` — both fire on real user interaction, so this is transparent 145 + // except when dispatching events from tests. 146 + const onLexiconChange = (e: Event) => { 147 + const value = (e.currentTarget as HTMLSelectElement).value; 148 + setLexiconValue(value); 149 + void run(paramsFromFilters(qValue, value)); 150 + }; 151 + 152 + return ( 153 + <form 154 + class={s.filterBar} 155 + onSubmit={(e: Event) => { 156 + e.preventDefault(); 157 + const params = paramsFromFilters(qValue, lexiconValue); 158 + if (canonical(params.toString()) === canonical(window.location.search)) return; 159 + void run(params); 160 + }} 161 + > 162 + <div class={s.field}> 163 + <label class={s.fieldLabel} for="q"> 164 + Search 165 + </label> 166 + <Input 167 + id="q" 168 + name="q" 169 + type="search" 170 + placeholder="Search automations..." 171 + value={qValue} 172 + onInput={onQInput} 173 + /> 174 + </div> 175 + <div class={s.field}> 176 + <label class={s.fieldLabel} for="lexicon"> 177 + Lexicon 178 + </label> 179 + <Select id="lexicon" name="lexicon" onChange={onLexiconChange}> 180 + <option value="">All lexicons</option> 181 + {lexiconOptions.map((row) => ( 182 + <option key={row.lexicon} value={row.lexicon} selected={row.lexicon === lexiconValue}> 183 + {row.lexicon} ({row.count}) 184 + </option> 185 + ))} 186 + </Select> 187 + </div> 188 + </form> 189 + ); 190 + }
+68 -81
app/routes/automations.tsx
··· 10 10 import { Container } from "../components/Layout/Container/index.js"; 11 11 import { PageHeader } from "../components/Layout/PageHeader/index.js"; 12 12 import { AutomationCard } from "../components/AutomationCard/index.js"; 13 - import { Input } from "../components/Input/index.js"; 14 - import { Select } from "../components/Select/index.js"; 15 13 import { Button } from "../components/Button/index.js"; 14 + import AutomationFilters from "../islands/AutomationFilters.js"; 16 15 import ThemeToggle from "../islands/ThemeToggle.js"; 17 16 import * as s from "../styles/pages/automations.css.js"; 18 17 ··· 58 57 59 58 c.header("Cache-Control", viewer ? "private, no-store" : "public, s-maxage=60"); 60 59 60 + // Swapped in place by the AutomationFilters island. 61 + // No islands may live inside this subtree — replaceWith drops their listeners. 62 + const results = ( 63 + <div id="automation-results"> 64 + {featured.length > 0 && ( 65 + <section class={s.featuredSection}> 66 + <h2 class={s.featuredTitle}>Featured</h2> 67 + <div class={s.grid}> 68 + {featured.map((a) => ( 69 + <AutomationCard 70 + key={`${a.did}/${a.rkey}`} 71 + handle={a.handle} 72 + did={a.did} 73 + rkey={a.rkey} 74 + name={a.name} 75 + description={a.description} 76 + lexicon={a.lexicon} 77 + actions={a.actions} 78 + viewerAuthenticated={Boolean(viewer)} 79 + isOwner={viewer?.did === a.did} 80 + featured 81 + /> 82 + ))} 83 + </div> 84 + </section> 85 + )} 86 + 87 + {visible.length > 0 ? ( 88 + <> 89 + <div class={s.grid}> 90 + {visible.map((a) => ( 91 + <AutomationCard 92 + key={`${a.did}/${a.rkey}`} 93 + handle={a.handle} 94 + did={a.did} 95 + rkey={a.rkey} 96 + name={a.name} 97 + description={a.description} 98 + lexicon={a.lexicon} 99 + actions={a.actions} 100 + viewerAuthenticated={Boolean(viewer)} 101 + isOwner={viewer?.did === a.did} 102 + /> 103 + ))} 104 + </div> 105 + {hasMore && ( 106 + <div class={s.loadMoreWrap}> 107 + <Button href={buildQuery({ limit: limit + PAGE_SIZE })} variant="secondary" size="sm"> 108 + Load more 109 + </Button> 110 + </div> 111 + )} 112 + </> 113 + ) : featured.length === 0 ? ( 114 + <p class={s.empty}> 115 + No automations match your filters. Try a broader search or pick a different lexicon. 116 + </p> 117 + ) : null} 118 + </div> 119 + ); 120 + 121 + if (c.req.header("X-Fragment") === "1") { 122 + return c.html(results); 123 + } 124 + 61 125 return c.render( 62 126 <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 63 127 <Container> ··· 66 130 description="Browse public automations. Duplicate any of them to your account." 67 131 /> 68 132 69 - <form method="get" action="/automations" class={s.filterBar}> 70 - <div class={s.field}> 71 - <label class={s.fieldLabel} for="q"> 72 - Search 73 - </label> 74 - <Input id="q" name="q" type="search" placeholder="Search automations..." value={q} /> 75 - </div> 76 - <div class={s.field}> 77 - <label class={s.fieldLabel} for="lexicon"> 78 - Lexicon 79 - </label> 80 - <Select id="lexicon" name="lexicon"> 81 - <option value="">All lexicons</option> 82 - {lexiconRows.map((row) => ( 83 - <option key={row.lexicon} value={row.lexicon} selected={row.lexicon === lexicon}> 84 - {row.lexicon} ({row.count}) 85 - </option> 86 - ))} 87 - </Select> 88 - </div> 89 - </form> 133 + <AutomationFilters q={q} lexicon={lexicon} lexiconOptions={lexiconRows} /> 90 134 91 - {featured.length > 0 && ( 92 - <section class={s.featuredSection}> 93 - <h2 class={s.featuredTitle}>Featured</h2> 94 - <div class={s.grid}> 95 - {featured.map((a) => ( 96 - <AutomationCard 97 - key={`${a.did}/${a.rkey}`} 98 - handle={a.handle} 99 - did={a.did} 100 - rkey={a.rkey} 101 - name={a.name} 102 - description={a.description} 103 - lexicon={a.lexicon} 104 - actions={a.actions} 105 - viewerAuthenticated={Boolean(viewer)} 106 - isOwner={viewer?.did === a.did} 107 - featured 108 - /> 109 - ))} 110 - </div> 111 - </section> 112 - )} 113 - 114 - {visible.length > 0 ? ( 115 - <> 116 - <div class={s.grid}> 117 - {visible.map((a) => ( 118 - <AutomationCard 119 - key={`${a.did}/${a.rkey}`} 120 - handle={a.handle} 121 - did={a.did} 122 - rkey={a.rkey} 123 - name={a.name} 124 - description={a.description} 125 - lexicon={a.lexicon} 126 - actions={a.actions} 127 - viewerAuthenticated={Boolean(viewer)} 128 - isOwner={viewer?.did === a.did} 129 - /> 130 - ))} 131 - </div> 132 - {hasMore && ( 133 - <div class={s.loadMoreWrap}> 134 - <Button 135 - href={buildQuery({ limit: limit + PAGE_SIZE })} 136 - variant="secondary" 137 - size="sm" 138 - > 139 - Load more 140 - </Button> 141 - </div> 142 - )} 143 - </> 144 - ) : featured.length === 0 ? ( 145 - <p class={s.empty}> 146 - No automations match your filters. Try a broader search or pick a different lexicon. 147 - </p> 148 - ) : null} 135 + {results} 149 136 </Container> 150 137 </AppShell>, 151 138 {
+9 -1
app/styles/pages/automations.css.ts
··· 1 - import { style } from "@vanilla-extract/css"; 1 + import { globalStyle, style } from "@vanilla-extract/css"; 2 2 import { vars } from "../theme.css.ts"; 3 3 import { space } from "../tokens/spacing.ts"; 4 4 import { fontSize, fontWeight } from "../tokens/typography.ts"; ··· 72 72 display: "flex", 73 73 justifyContent: "center", 74 74 }); 75 + 76 + globalStyle("#automation-results", { 77 + transition: "opacity 180ms ease", 78 + }); 79 + 80 + globalStyle('#automation-results[aria-busy="true"]', { 81 + opacity: 0.55, 82 + });