Created
February 12, 2026 17:57
-
-
Save jasikpark/41551000aba7ce3d86f3a5bf5bc50789 to your computer and use it in GitHub Desktop.
implementing a links page w/ atproto (coded w/ Claude Sonnet 4.5)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| import Card from "#components/Card.astro"; | |
| import FitWidthText from "#components/FitWidthText.astro"; | |
| import Pagination from "#components/Pagination.astro"; | |
| import { Icon } from "astro-icon/components"; | |
| import Layout from "src/layouts/Layout.astro"; | |
| import { fetchAllLinks, LINKS_PAGE_SIZE } from "#utils/atproto.ts"; | |
| export const prerender = false; | |
| const { links: allLinks, errors } = await fetchAllLinks(); | |
| const currentPage = Math.max(1, Number(Astro.url.searchParams.get("page")) || 1); | |
| const totalPages = Math.max(1, Math.ceil(allLinks.length / LINKS_PAGE_SIZE)); | |
| const page = Math.min(currentPage, totalPages); | |
| const links = allLinks.slice((page - 1) * LINKS_PAGE_SIZE, page * LINKS_PAGE_SIZE); | |
| // Tell the browser to always check the freshness of the cache | |
| Astro.response.headers.set("Cache-Control", "public, max-age=0, must-revalidate"); | |
| // Tell Netlify's CDN to treat it as fresh for 5 minutes, then for up to a week return a stale version | |
| // while it revalidates. Use Durable Cache to minimize the need for serverless function calls. | |
| Astro.response.headers.set( | |
| "Netlify-CDN-Cache-Control", "public, durable, s-maxage=300, stale-while-revalidate=604800" | |
| ); | |
| --- | |
| <Layout | |
| title={page > 1 ? `Links page ${page} of ${totalPages}` : "Links"} | |
| description="Saved links from across the web" | |
| addToPagefindIndex={false} | |
| > | |
| <main class="prose"> | |
| <header class="full-bleed-container @container"> | |
| <h1> | |
| <FitWidthText><a href="/" class="no-underline">🪴</a>/ Links</FitWidthText> | |
| </h1> | |
| <p class="mt-0 mb-[1.5em] font-semibold"> | |
| You can see links I've saved via <a href="https://margin.at/">https://margin.at/</a> and <a | |
| href="https://semble.so/">https://semble.so/</a | |
| > here. <a href="https://atproto.com/">ATproto</a> powered 💪 | |
| </p> | |
| </header> | |
| </main> | |
| { | |
| errors.length > 0 && allLinks.length > 0 && ( | |
| <div class="full-bleed-container"> | |
| <p class="rounded border border-amber-500 bg-amber-50 p-3 text-amber-900 dark:bg-amber-950 dark:text-amber-200"> | |
| Some sources failed to load: {errors.join("; ")} | |
| </p> | |
| </div> | |
| ) | |
| } | |
| { | |
| errors.length > 0 && allLinks.length === 0 ? | |
| <div class="full-bleed-container"> | |
| <p class="rounded border border-red-500 bg-red-50 p-3 text-red-900 dark:bg-red-950 dark:text-red-200"> | |
| Failed to load links. Please try again later. | |
| </p> | |
| </div> | |
| : <main class="prose"> | |
| <ul class="full-bleed-container shifty-list"> | |
| {links.map((link) => ( | |
| <li> | |
| <Card title={link.title} href={link.url}> | |
| <div> | |
| {link.description && ( | |
| <div class="mb-[0.75em] text-balance">{link.description}</div> | |
| )} | |
| <div class="flex flex-wrap items-center gap-2 text-[0.9em]"> | |
| <Icon | |
| class="inline align-text-bottom" | |
| name="ri:bookmark-fill" | |
| aria-hidden="true" | |
| /> | |
| <span> | |
| {link.createdAt.toLocaleDateString(undefined, { | |
| day: "2-digit", | |
| month: "short", | |
| year: "numeric", | |
| })} | |
| </span> | |
| <span class="rounded bg-current/10 px-1.5 py-0.5 text-xs"> | |
| {link.source === "semble" ? "Semble" : "margin.at"} | |
| </span> | |
| {link.contentType && ( | |
| <span class="rounded bg-current/10 px-1.5 py-0.5 text-xs"> | |
| {link.contentType} | |
| </span> | |
| )} | |
| {link.tags?.map((tag) => ( | |
| <span class="rounded bg-current/10 px-1.5 py-0.5 text-xs">{tag}</span> | |
| ))} | |
| </div> | |
| </div> | |
| </Card> | |
| </li> | |
| ))} | |
| </ul> | |
| {totalPages > 1 && ( | |
| <div class="mb-[1em] flex list-none justify-center"> | |
| <Pagination | |
| firstPage={page > 1 ? "/links" : null} | |
| previousPage={ | |
| page > 1 ? | |
| page === 2 ? | |
| "/links" | |
| : `/links?page=${page - 1}` | |
| : null | |
| } | |
| nextPage={page < totalPages ? `/links?page=${page + 1}` : null} | |
| lastPage={page < totalPages ? `/links?page=${totalPages}` : null} | |
| currentPage={page} | |
| totalPages={totalPages} | |
| /> | |
| </div> | |
| )} | |
| </main> | |
| } | |
| </Layout> | |
| <style> | |
| ul { | |
| margin-bottom: 2em; | |
| padding-inline-start: 0; | |
| list-style: ""; | |
| } | |
| li { | |
| margin-bottom: 1em; | |
| } | |
| </style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { AtpAgent } from "@atproto/api"; | |
| const DID = "did:plc:3tkrsjzdao4vqjrxwzynbfnu"; | |
| export type LinkItem = { | |
| url: string; | |
| title: string; | |
| description?: string; | |
| siteName?: string; | |
| contentType?: string; | |
| author?: string; | |
| tags?: string[]; | |
| createdAt: Date; | |
| source: "semble" | "margin"; | |
| }; | |
| function parseRecordDate(val: Record<string, unknown>, recordUri: string): Date { | |
| const createdAt = val.createdAt as string | undefined; | |
| if (createdAt) { | |
| return new Date(createdAt); | |
| } | |
| const rkey = recordUri.split("/").pop(); | |
| return new Date(rkey ?? 0); | |
| } | |
| async function fetchSembleCards( | |
| agent: AtpAgent, | |
| did: string, | |
| limit: number, | |
| signal: AbortSignal | |
| ): Promise<LinkItem[]> { | |
| const res = await agent.com.atproto.repo.listRecords( | |
| { | |
| repo: did, | |
| collection: "network.cosmik.card", | |
| limit, | |
| }, | |
| { signal } | |
| ); | |
| const items: LinkItem[] = []; | |
| for (const record of res.data.records) { | |
| const val = record.value as Record<string, unknown>; | |
| const content = val.content as Record<string, unknown> | undefined; | |
| const url = content?.url as string | undefined; | |
| if (!url) { | |
| continue; | |
| } | |
| const metadata = content?.metadata as Record<string, unknown> | undefined; | |
| items.push({ | |
| url, | |
| title: (metadata?.title as string) || url, | |
| description: metadata?.description as string | undefined, | |
| siteName: metadata?.siteName as string | undefined, | |
| contentType: metadata?.type as string | undefined, | |
| author: metadata?.author as string | undefined, | |
| createdAt: parseRecordDate(val, record.uri), | |
| source: "semble", | |
| }); | |
| } | |
| return items; | |
| } | |
| async function fetchMarginBookmarks( | |
| agent: AtpAgent, | |
| did: string, | |
| limit: number, | |
| signal: AbortSignal | |
| ): Promise<LinkItem[]> { | |
| const res = await agent.com.atproto.repo.listRecords( | |
| { | |
| repo: did, | |
| collection: "at.margin.bookmark", | |
| limit, | |
| }, | |
| { signal } | |
| ); | |
| const items: LinkItem[] = []; | |
| for (const record of res.data.records) { | |
| const val = record.value as Record<string, unknown>; | |
| const url = val.source as string | undefined; | |
| if (!url) { | |
| continue; | |
| } | |
| items.push({ | |
| url, | |
| title: (val.title as string) || url, | |
| description: val.description as string | undefined, | |
| tags: val.tags as string[] | undefined, | |
| createdAt: parseRecordDate(val, record.uri), | |
| source: "margin", | |
| }); | |
| } | |
| return items; | |
| } | |
| export const LINKS_PAGE_SIZE = 20; | |
| export async function fetchAllLinks(limit = 100): Promise<{ links: LinkItem[]; errors: string[] }> { | |
| const agent = new AtpAgent({ service: "https://bsky.social" }); | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => { | |
| controller.abort(); | |
| }, 10_000); | |
| try { | |
| const [sembleResult, marginResult] = await Promise.allSettled([ | |
| fetchSembleCards(agent, DID, limit, controller.signal), | |
| fetchMarginBookmarks(agent, DID, limit, controller.signal), | |
| ]); | |
| const links: LinkItem[] = []; | |
| const errors: string[] = []; | |
| if (sembleResult.status === "fulfilled") { | |
| links.push(...sembleResult.value); | |
| } else { | |
| errors.push(`Semble: ${String(sembleResult.reason)}`); | |
| } | |
| if (marginResult.status === "fulfilled") { | |
| links.push(...marginResult.value); | |
| } else { | |
| errors.push(`margin.at: ${String(marginResult.reason)}`); | |
| } | |
| // Deduplicate by URL, preferring the first occurrence | |
| const seen = new Set<string>(); | |
| const deduped: LinkItem[] = []; | |
| for (const link of links) { | |
| const normalized = link.url.replace(/\/+$/, ""); | |
| if (!seen.has(normalized)) { | |
| seen.add(normalized); | |
| deduped.push(link); | |
| } | |
| } | |
| // Sort newest first | |
| deduped.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); | |
| return { links: deduped, errors }; | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment