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.

feat: show automation gallery in public profile

Hugo b1e5e193 22a41a8d

+171 -125
+81
app/components/AutomationGallery/index.tsx
··· 1 + import type { AutomationSearchResult } from "../../../lib/automations/search.ts"; 2 + import { AutomationCard } from "../AutomationCard/index.tsx"; 3 + import { Button } from "../Button/index.tsx"; 4 + import * as s from "../../styles/pages/automations.css.ts"; 5 + 6 + type Props = { 7 + rows: AutomationSearchResult[]; 8 + featured?: AutomationSearchResult[]; 9 + viewerDid?: string; 10 + viewerAuthenticated: boolean; 11 + hasMore: boolean; 12 + loadMoreHref: string; 13 + emptyMessage?: string; 14 + }; 15 + 16 + export function AutomationGallery({ 17 + rows, 18 + featured = [], 19 + viewerDid, 20 + viewerAuthenticated, 21 + hasMore, 22 + loadMoreHref, 23 + emptyMessage = "No automations match your search. Try a broader search.", 24 + }: Props) { 25 + return ( 26 + <div id="automation-results"> 27 + {featured.length > 0 && ( 28 + <section class={s.featuredSection}> 29 + <h2 class={s.featuredTitle}>Featured</h2> 30 + <div class={s.grid}> 31 + {featured.map((a) => ( 32 + <AutomationCard 33 + key={`${a.did}/${a.rkey}`} 34 + handle={a.handle} 35 + did={a.did} 36 + rkey={a.rkey} 37 + name={a.name} 38 + description={a.description} 39 + lexicon={a.lexicon} 40 + actions={a.actions} 41 + viewerAuthenticated={viewerAuthenticated} 42 + isOwner={viewerDid === a.did} 43 + featured 44 + /> 45 + ))} 46 + </div> 47 + </section> 48 + )} 49 + 50 + {rows.length > 0 ? ( 51 + <> 52 + <div class={s.grid}> 53 + {rows.map((a) => ( 54 + <AutomationCard 55 + key={`${a.did}/${a.rkey}`} 56 + handle={a.handle} 57 + did={a.did} 58 + rkey={a.rkey} 59 + name={a.name} 60 + description={a.description} 61 + lexicon={a.lexicon} 62 + actions={a.actions} 63 + viewerAuthenticated={viewerAuthenticated} 64 + isOwner={viewerDid === a.did} 65 + /> 66 + ))} 67 + </div> 68 + {hasMore && ( 69 + <div class={s.loadMoreWrap}> 70 + <Button href={loadMoreHref} variant="secondary" size="sm"> 71 + Load more 72 + </Button> 73 + </div> 74 + )} 75 + </> 76 + ) : featured.length === 0 ? ( 77 + <p class={s.empty}>{emptyMessage}</p> 78 + ) : null} 79 + </div> 80 + ); 81 + }
+7 -5
app/islands/AutomationFilters.tsx
··· 4 4 5 5 type Props = { 6 6 q: string; 7 + basePath?: string; 7 8 }; 8 9 9 10 const DEBOUNCE_MS = 300; 10 11 const BUSY_DELAY_MS = 120; 11 12 const MIN_Q_LENGTH = 2; 12 13 const RESULTS_ID = "automation-results"; 14 + const DEFAULT_BASE_PATH = "/automations"; 13 15 14 - const buildDisplayUrl = (params: URLSearchParams) => { 16 + const buildDisplayUrl = (basePath: string, params: URLSearchParams) => { 15 17 const qs = params.toString(); 16 - return qs ? `/automations?${qs}` : "/automations"; 18 + return qs ? `${basePath}?${qs}` : basePath; 17 19 }; 18 20 19 21 const paramsFromFilters = (q: string) => { ··· 42 44 if (next && current) current.replaceWith(next); 43 45 }; 44 46 45 - export default function AutomationFilters({ q }: Props) { 47 + export default function AutomationFilters({ q, basePath = DEFAULT_BASE_PATH }: Props) { 46 48 const [qValue, setQValue] = useState(q); 47 49 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 48 50 const abortRef = useRef<AbortController | null>(null); ··· 58 60 abortRef.current = ctrl; 59 61 60 62 const prevUrl = window.location.pathname + window.location.search; 61 - const displayUrl = buildDisplayUrl(nextParams); 63 + const displayUrl = buildDisplayUrl(basePath, nextParams); 62 64 const urlChanged = canonical(nextParams.toString()) !== canonical(window.location.search); 63 65 if (urlChanged) history.pushState(null, "", displayUrl); 64 66 ··· 97 99 if (!anchor) return; 98 100 if (!anchor.closest(`#${RESULTS_ID}`)) return; 99 101 const href = anchor.getAttribute("href") ?? ""; 100 - if (!href.startsWith("/automations?")) return; 102 + if (!href.startsWith(`${basePath}?`)) return; 101 103 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; 102 104 e.preventDefault(); 103 105 const qs = href.slice(href.indexOf("?") + 1);
+9 -57
app/routes/automations.tsx
··· 7 7 import { Header } from "../components/Layout/Header/index.js"; 8 8 import { Container } from "../components/Layout/Container/index.js"; 9 9 import { PageHeader } from "../components/Layout/PageHeader/index.js"; 10 - import { AutomationCard } from "../components/AutomationCard/index.js"; 11 - import { Button } from "../components/Button/index.js"; 10 + import { AutomationGallery } from "../components/AutomationGallery/index.js"; 12 11 import AutomationFilters from "../islands/AutomationFilters.js"; 13 12 import ThemeToggle from "../islands/ThemeToggle.js"; 14 - import * as s from "../styles/pages/automations.css.js"; 15 13 16 14 const PAGE_SIZE = 18; 17 15 ··· 48 46 // Swapped in place by the AutomationFilters island. 49 47 // No islands may live inside this subtree — replaceWith drops their listeners. 50 48 const results = ( 51 - <div id="automation-results"> 52 - {featured.length > 0 && ( 53 - <section class={s.featuredSection}> 54 - <h2 class={s.featuredTitle}>Featured</h2> 55 - <div class={s.grid}> 56 - {featured.map((a) => ( 57 - <AutomationCard 58 - key={`${a.did}/${a.rkey}`} 59 - handle={a.handle} 60 - did={a.did} 61 - rkey={a.rkey} 62 - name={a.name} 63 - description={a.description} 64 - lexicon={a.lexicon} 65 - actions={a.actions} 66 - viewerAuthenticated={Boolean(viewer)} 67 - isOwner={viewer?.did === a.did} 68 - featured 69 - /> 70 - ))} 71 - </div> 72 - </section> 73 - )} 74 - 75 - {visible.length > 0 ? ( 76 - <> 77 - <div class={s.grid}> 78 - {visible.map((a) => ( 79 - <AutomationCard 80 - key={`${a.did}/${a.rkey}`} 81 - handle={a.handle} 82 - did={a.did} 83 - rkey={a.rkey} 84 - name={a.name} 85 - description={a.description} 86 - lexicon={a.lexicon} 87 - actions={a.actions} 88 - viewerAuthenticated={Boolean(viewer)} 89 - isOwner={viewer?.did === a.did} 90 - /> 91 - ))} 92 - </div> 93 - {hasMore && ( 94 - <div class={s.loadMoreWrap}> 95 - <Button href={buildQuery({ limit: limit + PAGE_SIZE })} variant="secondary" size="sm"> 96 - Load more 97 - </Button> 98 - </div> 99 - )} 100 - </> 101 - ) : featured.length === 0 ? ( 102 - <p class={s.empty}>No automations match your search. Try a broader search.</p> 103 - ) : null} 104 - </div> 49 + <AutomationGallery 50 + rows={visible} 51 + featured={featured} 52 + viewerDid={viewer?.did} 53 + viewerAuthenticated={Boolean(viewer)} 54 + hasMore={hasMore} 55 + loadMoreHref={buildQuery({ limit: limit + PAGE_SIZE })} 56 + /> 105 57 ); 106 58 107 59 if (isFragment) {
+69 -63
app/routes/u/[handle]/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { eq, inArray, like, count } from "drizzle-orm"; 3 - import { Eye, Zap, Globe } from "../../../icons.js"; 2 + import { and, eq, inArray, like, count } from "drizzle-orm"; 3 + import { Zap, Globe } from "../../../icons.js"; 4 4 import { getSessionUser } from "@/auth/middleware.js"; 5 5 import { resolveHandle } from "@/auth/client.js"; 6 + import { searchAutomations } from "@/automations/search.js"; 6 7 import { db } from "@/db/index.js"; 7 8 import { users, automations } from "@/db/schema.js"; 8 9 import { ··· 15 16 import { Header } from "../../../components/Layout/Header/index.js"; 16 17 import { Container } from "../../../components/Layout/Container/index.js"; 17 18 import { PageHeader } from "../../../components/Layout/PageHeader/index.js"; 18 - import { Badge } from "../../../components/Badge/index.js"; 19 19 import { Button } from "../../../components/Button/index.js"; 20 20 import { Table } from "../../../components/Table/index.js"; 21 - import { InlineCode } from "../../../components/CodeBlock/index.js"; 22 21 import { NsidCode } from "../../../components/NsidCode/index.js"; 23 22 import { Stack } from "../../../components/Layout/Stack/index.js"; 23 + import { AutomationGallery } from "../../../components/AutomationGallery/index.js"; 24 + import AutomationFilters from "../../../islands/AutomationFilters.js"; 24 25 import ThemeToggle from "../../../islands/ThemeToggle.js"; 25 26 import * as s from "../../../styles/pages/profile.css.js"; 26 27 import { centerTextSm } from "../../../styles/utilities.css.js"; 27 28 29 + const PAGE_SIZE = 18; 30 + 28 31 export default createRoute(async (c) => { 29 32 const viewer = await getSessionUser(c); 30 33 const handle = c.req.param("handle")!; 34 + 35 + const q = (c.req.query("q") ?? "").trim().slice(0, 128); 36 + const limit = Math.min(Math.max(Number(c.req.query("limit")) || PAGE_SIZE, PAGE_SIZE), 200); 37 + const isFragment = c.req.header("X-Fragment") === "1"; 31 38 32 39 // Resolve handle → user in DB 33 40 let profileUser = await db.query.users.findFirst({ ··· 44 51 } 45 52 } 46 53 47 - // Fetch automations if user exists 48 - const autos = profileUser 49 - ? await db.query.automations.findMany({ 50 - where: eq(automations.did, profileUser.did), 54 + // Count active automations only — matches what the gallery renders, so the header 55 + // count and the visible cards agree. Inactive ones are surfaced in /dashboard. 56 + const totalAutos = profileUser 57 + ? (( 58 + await db 59 + .select({ count: count() }) 60 + .from(automations) 61 + .where(and(eq(automations.did, profileUser.did), eq(automations.active, true))) 62 + )[0]?.count ?? 0) 63 + : 0; 64 + 65 + const rows = profileUser 66 + ? await searchAutomations(db, { 67 + q, 68 + limit: limit + 1, 69 + did: profileUser.did, 51 70 }) 52 71 : []; 72 + const hasMore = rows.length > limit; 73 + const visible = hasMore ? rows.slice(0, limit) : rows; 74 + 75 + const buildQuery = (overrides: { limit?: number }) => { 76 + const p = new URLSearchParams(); 77 + if (q) p.set("q", q); 78 + if (overrides.limit) p.set("limit", String(overrides.limit)); 79 + const qs = p.toString(); 80 + return qs ? `/u/${handle}?${qs}` : `/u/${handle}`; 81 + }; 53 82 54 83 // Check if handle is a lexicon authority 55 84 const nsidPrefix = handleToNsidPrefix(handle); ··· 71 100 // Count automations subscribed to each lexicon 72 101 const subCounts = new Map<string, number>(); 73 102 if (lexicons.length > 0) { 74 - const rows = await db 103 + const subRows = await db 75 104 .select({ lexicon: automations.lexicon, count: count() }) 76 105 .from(automations) 77 106 .where(inArray(automations.lexicon, lexicons)) 78 107 .groupBy(automations.lexicon); 79 - for (const row of rows) { 108 + for (const row of subRows) { 80 109 subCounts.set(row.lexicon, row.count); 81 110 } 82 111 } 83 112 lexicons.sort((a, b) => (subCounts.get(b) ?? 0) - (subCounts.get(a) ?? 0)); 84 113 85 - // 404 if nothing to show 86 - if (autos.length === 0 && lexicons.length === 0) { 114 + // 404 only when the handle resolves to nothing — neither a known user nor a lexicon 115 + // authority. A registered user with everything paused still gets a real page below. 116 + if (!profileUser && lexicons.length === 0) { 87 117 c.status(404); 88 118 return c.render( 89 119 <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> ··· 101 131 ); 102 132 } 103 133 134 + const emptyMessage = q 135 + ? `@${handle} has no automations matching your search.` 136 + : `@${handle} has no public automations.`; 137 + 138 + const gallery = ( 139 + <AutomationGallery 140 + rows={visible} 141 + viewerDid={viewer?.did} 142 + viewerAuthenticated={Boolean(viewer)} 143 + hasMore={hasMore} 144 + loadMoreHref={buildQuery({ limit: limit + PAGE_SIZE })} 145 + emptyMessage={emptyMessage} 146 + /> 147 + ); 148 + 149 + if (isFragment) { 150 + return c.html(gallery); 151 + } 152 + 104 153 // If viewing own profile, redirect hint 105 154 const isOwnProfile = viewer && profileUser && viewer.did === profileUser.did; 106 155 ··· 111 160 title={`@${profileUser?.handle ?? handle}`} 112 161 description={ 113 162 [ 114 - autos.length > 0 ? `${autos.length} automation${autos.length !== 1 ? "s" : ""}` : "", 163 + totalAutos > 0 ? `${totalAutos} automation${totalAutos !== 1 ? "s" : ""}` : "", 115 164 lexicons.length > 0 116 165 ? `${lexicons.length} lexicon${lexicons.length !== 1 ? "s" : ""}` 117 166 : "", ··· 129 178 /> 130 179 131 180 <Stack gap={6}> 132 - {autos.length > 0 && ( 181 + {totalAutos === 0 && lexicons.length === 0 && ( 182 + <p class={centerTextSm}>@{profileUser?.handle ?? handle} has nothing public yet.</p> 183 + )} 184 + 185 + {totalAutos > 0 && ( 133 186 <section class={s.section}> 134 187 <h2 class={s.sectionTitle}> 135 188 <Zap size={18} /> Automations 136 189 </h2> 137 - <Table> 138 - <thead> 139 - <tr> 140 - <th>Name</th> 141 - <th>Lexicon</th> 142 - <th>Operations</th> 143 - <th>Actions</th> 144 - <th>Status</th> 145 - <th></th> 146 - </tr> 147 - </thead> 148 - <tbody> 149 - {autos.map((auto) => ( 150 - <tr key={auto.uri}> 151 - <td> 152 - <a href={`/u/${handle}/${auto.rkey}`}>{auto.name}</a> 153 - </td> 154 - <td> 155 - <a href={`/lexicons/${auto.lexicon}`}> 156 - <NsidCode>{auto.lexicon}</NsidCode> 157 - </a> 158 - </td> 159 - <td> 160 - {auto.operations.map((op, i) => ( 161 - <> 162 - {i > 0 && ", "} 163 - <InlineCode>{op}</InlineCode> 164 - </> 165 - ))} 166 - </td> 167 - <td> 168 - {auto.actions.length} action{auto.actions.length !== 1 ? "s" : ""} 169 - </td> 170 - <td> 171 - <Badge 172 - variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"} 173 - > 174 - {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 175 - </Badge> 176 - </td> 177 - <td> 178 - <Button href={`/u/${handle}/${auto.rkey}`} variant="ghost" size="sm"> 179 - <Eye size={14} /> View 180 - </Button> 181 - </td> 182 - </tr> 183 - ))} 184 - </tbody> 185 - </Table> 190 + <AutomationFilters q={q} basePath={`/u/${handle}`} /> 191 + {gallery} 186 192 </section> 187 193 )} 188 194
+5
lib/automations/search.ts
··· 22 22 q?: string; 23 23 limit: number; 24 24 excludeUris?: string[]; 25 + did?: string; 25 26 }; 26 27 27 28 export async function searchAutomations( ··· 29 30 params: AutomationSearchParams, 30 31 ): Promise<AutomationSearchResult[]> { 31 32 const conditions = [eq(automations.active, true)]; 33 + 34 + if (params.did) { 35 + conditions.push(eq(automations.did, params.did)); 36 + } 32 37 33 38 if (params.q) { 34 39 // Escape LIKE wildcards with a backslash and declare ESCAPE '\' so '%' and