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: better profile and lexicon pages

Hugo c6d1ab39 4883e355

+71 -7
+38
app/routes/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 + import { desc, count } from "drizzle-orm"; 2 3 import { Webhook, FilePlus2, Filter, Activity } from "../icons.js"; 3 4 import { getSessionUser } from "@/auth/middleware.js"; 5 + import { db } from "@/db/index.js"; 6 + import { automations } from "@/db/schema.js"; 4 7 import { AppShell } from "../components/Layout/AppShell/index.js"; 5 8 import { Header } from "../components/Layout/Header/index.js"; 6 9 import { Container } from "../components/Layout/Container/index.js"; 7 10 import { Button } from "../components/Button/index.js"; 11 + import { Table } from "../components/Table/index.js"; 12 + import { InlineCode } from "../components/CodeBlock/index.js"; 8 13 import ThemeToggle from "../islands/ThemeToggle.js"; 9 14 import * as s from "../styles/pages/landing.css.js"; 10 15 11 16 export default createRoute(async (c) => { 12 17 const user = await getSessionUser(c); 18 + 19 + const topLexicons = await db 20 + .select({ lexicon: automations.lexicon, count: count() }) 21 + .from(automations) 22 + .groupBy(automations.lexicon) 23 + .orderBy(desc(count())) 24 + .limit(10); 13 25 14 26 return c.render( 15 27 <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> ··· 73 85 </p> 74 86 </div> 75 87 </section> 88 + 89 + {topLexicons.length > 0 && ( 90 + <section class={s.topLexicons}> 91 + <h2 class={s.stepsTitle}>Popular Lexicons</h2> 92 + <Table> 93 + <thead> 94 + <tr> 95 + <th>NSID</th> 96 + <th>Automations</th> 97 + </tr> 98 + </thead> 99 + <tbody> 100 + {topLexicons.map((row) => ( 101 + <tr key={row.lexicon}> 102 + <td> 103 + <a href={`/lexicons/${row.lexicon}`}> 104 + <InlineCode>{row.lexicon}</InlineCode> 105 + </a> 106 + </td> 107 + <td>{row.count}</td> 108 + </tr> 109 + ))} 110 + </tbody> 111 + </Table> 112 + </section> 113 + )} 76 114 77 115 <section class={s.steps}> 78 116 <h2 class={s.stepsTitle}>How It Works</h2>
+13 -3
app/routes/lexicons/[nsid].tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq } from "drizzle-orm"; 3 - import { ArrowLeft, Eye, Zap } from "../../icons.js"; 3 + import { ArrowLeft, Eye, Plus, Zap } from "../../icons.js"; 4 4 import { getSessionUser } from "@/auth/middleware.js"; 5 5 import { db } from "@/db/index.js"; 6 6 import { automations, users } from "@/db/schema.js"; 7 - import { isValidNsid, nsidToAuthority, resolve } from "@/lexicons/resolver.js"; 7 + import { isValidNsid, resolve } from "@/lexicons/resolver.js"; 8 8 import { getCached, setCache } from "@/lexicons/cache.js"; 9 9 import { AppShell } from "../../components/Layout/AppShell/index.js"; 10 10 import { Header } from "../../components/Layout/Header/index.js"; ··· 70 70 if (row) handleByDid.set(row.did, row.handle); 71 71 } 72 72 73 - const authority = nsidToAuthority(nsid); 73 + // Use base 2-segment domain for profile link (e.g. "dev.npmx.feed.like" → "npmx.dev") 74 + const authority = nsid.split(".").slice(0, 2).reverse().join("."); 74 75 75 76 return c.render( 76 77 <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> ··· 83 84 <Button href={`/u/${authority}`} variant="ghost" size="sm"> 84 85 <ArrowLeft size={14} /> @{authority} 85 86 </Button> 87 + {viewer && ( 88 + <Button 89 + href={`/dashboard/automations/new?lexicon=${encodeURIComponent(nsid)}`} 90 + variant="secondary" 91 + size="sm" 92 + > 93 + <Plus size={14} /> Create automation 94 + </Button> 95 + )} 86 96 </div> 87 97 } 88 98 />
+12 -2
app/routes/u/[handle]/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { eq, inArray, count } from "drizzle-orm"; 2 + import { eq, inArray, like, count } from "drizzle-orm"; 3 3 import { Eye, Zap, Globe } from "../../../icons.js"; 4 4 import { getSessionUser } from "@/auth/middleware.js"; 5 5 import { resolveHandle } from "@/auth/client.js"; ··· 56 56 collectLocalNsids(nsidPrefix, lexiconResults); 57 57 await collectCachedNsids(nsidPrefix, lexiconResults); 58 58 await collectRemoteNsids(nsidPrefix, lexiconResults); 59 - const lexicons = [...lexiconResults].filter((nsid) => nsid.startsWith(nsidPrefix)).sort(); 59 + // Also discover lexicons from existing automations 60 + const autoLexiconRows = await db 61 + .selectDistinct({ lexicon: automations.lexicon }) 62 + .from(automations) 63 + .where(like(automations.lexicon, `${nsidPrefix}%`)); 64 + for (const row of autoLexiconRows) { 65 + lexiconResults.add(row.lexicon); 66 + } 67 + 68 + const lexicons = [...lexiconResults].filter((nsid) => nsid.startsWith(nsidPrefix)); 60 69 61 70 // Count automations subscribed to each lexicon 62 71 const subCounts = new Map<string, number>(); ··· 70 79 subCounts.set(row.lexicon, row.count); 71 80 } 72 81 } 82 + lexicons.sort((a, b) => (subCounts.get(b) ?? 0) - (subCounts.get(a) ?? 0)); 73 83 74 84 // 404 if nothing to show 75 85 if (autos.length === 0 && lexicons.length === 0) {
+5
app/styles/pages/landing.css.ts
··· 135 135 fontSize: fontSize.sm, 136 136 color: vars.color.textSecondary, 137 137 }); 138 + 139 + export const topLexicons = style({ 140 + paddingBlock: space[7], 141 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 142 + });
+1
app/styles/pages/profile.css.ts
··· 15 15 alignItems: "center", 16 16 gap: space[2], 17 17 color: vars.color.textSecondary, 18 + textWrap: "nowrap", 18 19 }); 19 20 20 21 globalStyle(`${sectionTitle} svg`, {
+2 -2
lib/lexicons/resolver.ts
··· 24 24 } 25 25 26 26 /** 27 - * Derive the authority domain from an NSID. 28 - * e.g. "sh.tangled.feed.star" -> "tangled.sh" 27 + * Derive the full authority domain from an NSID (all segments except the last, reversed). 28 + * e.g. "sh.tangled.feed.star" -> "feed.tangled.sh" 29 29 */ 30 30 export function nsidToAuthority(nsid: string): string { 31 31 const parts = nsid.split(".");