this repo has no description
0
fork

Configure Feed

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

leaflet yolo vibecode

alice 1d8f8290 eac50013

+871 -21
+24 -1
bun.lock
··· 7 7 "dependencies": { 8 8 "@atcute/client": "^2.0.9", 9 9 "@atcute/whitewind": "^1.0.4", 10 + "@atproto/api": "^0.18.0", 10 11 "bright": "^0.8.6", 11 12 "lucide-react": "^0.453.0", 12 13 "next": "16.0.3", ··· 56 57 "@atcute/client": ["@atcute/client@2.0.9", "", {}, "sha512-QNDm9gMP6x9LY77ArwY+urQOBtQW74/onEAz42c40JxRm6Rl9K9cU4ROvNKJ+5cpVmEm1sthEWVRmDr5CSZENA=="], 57 58 58 59 "@atcute/whitewind": ["@atcute/whitewind@1.0.4", "", { "peerDependencies": { "@atcute/client": "^1.0.0 || ^2.0.0" } }, "sha512-kxgpfVBLaNuHNxTQw8J34ZbD7lWId42tPKhuijiV61ha+y2lDABvDcQTX9hugIVdO9amy8PCPbyTKct5ITEEbQ=="], 60 + 61 + "@atproto/api": ["@atproto/api@0.18.0", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA=="], 62 + 63 + "@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg=="], 64 + 65 + "@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="], 66 + 67 + "@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="], 68 + 69 + "@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="], 59 70 60 71 "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], 61 72 ··· 400 411 "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], 401 412 402 413 "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], 414 + 415 + "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 403 416 404 417 "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], 405 418 ··· 759 772 760 773 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 761 774 775 + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 776 + 762 777 "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], 763 778 764 779 "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], ··· 910 925 "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], 911 926 912 927 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 928 + 929 + "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 913 930 914 931 "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], 915 932 ··· 1142 1159 "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], 1143 1160 1144 1161 "tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="], 1162 + 1163 + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 1145 1164 1146 1165 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 1147 1166 ··· 1173 1192 1174 1193 "typescript-eslint": ["typescript-eslint@8.46.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.4", "@typescript-eslint/parser": "8.46.4", "@typescript-eslint/typescript-estree": "8.46.4", "@typescript-eslint/utils": "8.46.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg=="], 1175 1194 1195 + "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1196 + 1176 1197 "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], 1177 1198 1178 1199 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], ··· 1229 1250 1230 1251 "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1231 1252 1232 - "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], 1253 + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1233 1254 1234 1255 "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], 1235 1256 ··· 1308 1329 "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], 1309 1330 1310 1331 "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 1332 + 1333 + "eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], 1311 1334 1312 1335 "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 1313 1336
+19
eslint.config.mjs
··· 1 + import { defineConfig } from "eslint/config"; 2 + import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; 3 + import nextTypescript from "eslint-config-next/typescript"; 4 + import path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import js from "@eslint/js"; 7 + import { FlatCompat } from "@eslint/eslintrc"; 8 + 9 + const __filename = fileURLToPath(import.meta.url); 10 + const __dirname = path.dirname(__filename); 11 + const compat = new FlatCompat({ 12 + baseDirectory: __dirname, 13 + recommendedConfig: js.configs.recommended, 14 + allConfig: js.configs.all 15 + }); 16 + 17 + export default defineConfig([{ 18 + extends: [...nextCoreWebVitals, ...nextTypescript, ...compat.extends("prettier")], 19 + }]);
+5
next.config.mjs
··· 8 8 pathname: "/xrpc/com.atproto.sync.getBlob", 9 9 // search: '?did=did%3Aplc%3Ap2cp5gopk7mgjegy6wadk3ep&cid=**', 10 10 }, 11 + { 12 + protocol: "https", 13 + hostname: "cdn.bsky.app", 14 + pathname: "/img/**", 15 + }, 11 16 ], 12 17 }, 13 18 };
+1
package.json
··· 13 13 "dependencies": { 14 14 "@atcute/client": "^2.0.9", 15 15 "@atcute/whitewind": "^1.0.4", 16 + "@atproto/api": "^0.18.0", 16 17 "bright": "^0.8.6", 17 18 "lucide-react": "^0.453.0", 18 19 "next": "16.0.3",
+82
src/app/leaflet/[rkey]/page.tsx
··· 1 + import { ArrowLeftIcon } from "lucide-react"; 2 + import { type Metadata } from "next"; 3 + import Link from "next/link"; 4 + 5 + import { LeafletContent } from "#/components/leaflet/leaflet-content"; 6 + import { Footer } from "#/components/footer"; 7 + import { PostInfo } from "#/components/post-info"; 8 + import { Title } from "#/components/typography"; 9 + import { getLeafletPost, getLeafletPosts } from "#/lib/leaflet/api"; 10 + import { extractLeafletPlaintext } from "#/lib/leaflet/plaintext"; 11 + import type { LeafletDocumentRecord } from "#/lib/leaflet/types"; 12 + import { AUTHOR_NAME, HOSTNAME } from "#/lib/config"; 13 + 14 + export const dynamic = "force-static"; 15 + export const revalidate = 3600; 16 + 17 + export async function generateStaticParams() { 18 + try { 19 + const posts = await getLeafletPosts(); 20 + return posts 21 + .map((post) => ({ rkey: post.uri.split("/").pop() })) 22 + .filter((post): post is { rkey: string } => Boolean(post.rkey)); 23 + } catch (error) { 24 + console.warn("Failed to prefetch Leaflet posts", error); 25 + return []; 26 + } 27 + } 28 + 29 + export async function generateMetadata({ 30 + params, 31 + }: { 32 + params: Promise<{ rkey: string }>; 33 + }): Promise<Metadata> { 34 + const { rkey } = await params; 35 + const post = await getLeafletPost(rkey); 36 + const record = post.value as LeafletDocumentRecord; 37 + const description = record.description || extractLeafletPlaintext(record).slice(0, 160); 38 + 39 + return { 40 + title: `${record.title} — ${HOSTNAME}`, 41 + description, 42 + alternates: { 43 + canonical: `https://${HOSTNAME}/leaflet/${rkey}`, 44 + }, 45 + authors: [{ name: AUTHOR_NAME, url: `https://bsky.app/profile/${record.author}` }], 46 + }; 47 + } 48 + 49 + export default async function LeafletPostPage({ 50 + params, 51 + }: { 52 + params: Promise<{ rkey: string }>; 53 + }) { 54 + const { rkey } = await params; 55 + const post = await getLeafletPost(rkey); 56 + const record = post.value as LeafletDocumentRecord; 57 + const content = extractLeafletPlaintext(record); 58 + 59 + return ( 60 + <div className="xs:px-8 grid min-h-dvh grid-rows-[10px_1fr_20px] justify-items-center gap-16 px-4 py-2 pb-20 sm:p-8"> 61 + <link rel="alternate" href={post.uri} /> 62 + <main className="row-start-2 flex w-full max-w-[600px] flex-col items-center gap-0 overflow-hidden sm:items-start"> 63 + <article className="w-full space-y-4"> 64 + <div className="w-full space-y-4"> 65 + <Link 66 + href="/" 67 + className="font-medium hover:underline hover:underline-offset-4" 68 + > 69 + <ArrowLeftIcon className="mb-px mr-1 inline size-4 align-middle" /> 70 + Back 71 + </Link> 72 + <Title>{record.title}</Title> 73 + <PostInfo content={content} createdAt={record.publishedAt} includeAuthor /> 74 + <hr className="border-slate-800/10 dark:border-slate-100/10" /> 75 + </div> 76 + <LeafletContent record={record} /> 77 + </article> 78 + </main> 79 + <Footer /> 80 + </div> 81 + ); 82 + }
+277
src/components/leaflet/leaflet-content.tsx
··· 1 + import Image from "next/image"; 2 + import Link from "next/link"; 3 + import type { JSX } from "react"; 4 + 5 + import { LeafletRichText } from "#/components/leaflet/rich-text"; 6 + import type { 7 + LeafletBlock, 8 + LeafletBskyPostBlock, 9 + LeafletDocumentRecord, 10 + LeafletLinearBlock, 11 + LeafletLinearPage, 12 + LeafletListItem, 13 + LeafletOrderedListBlock, 14 + LeafletUnorderedListBlock, 15 + } from "#/lib/leaflet/types"; 16 + 17 + const alignmentClasses: Record<string, string> = { 18 + "lex:pub.leaflet.pages.linearDocument#textAlignLeft": "items-start text-left", 19 + "lex:pub.leaflet.pages.linearDocument#textAlignCenter": "items-center text-center", 20 + "lex:pub.leaflet.pages.linearDocument#textAlignRight": "items-end text-right", 21 + "lex:pub.leaflet.pages.linearDocument#textAlignJustify": "items-stretch text-justify", 22 + }; 23 + 24 + export function LeafletContent({ 25 + record, 26 + }: { 27 + record: LeafletDocumentRecord; 28 + }) { 29 + const pages = record.pages.filter( 30 + (page): page is LeafletLinearPage => 31 + page?.$type === "pub.leaflet.pages.linearDocument", 32 + ); 33 + 34 + return ( 35 + <div className="flex w-full flex-col gap-6"> 36 + {pages.map((page, pageIndex) => ( 37 + <div key={page.id ?? pageIndex} className="flex flex-col gap-4"> 38 + {page.blocks.map((block, blockIndex) => ( 39 + <LeafletBlockRenderer 40 + key={`${pageIndex}-${blockIndex}`} 41 + block={block} 42 + authorDid={record.author} 43 + /> 44 + ))} 45 + </div> 46 + ))} 47 + </div> 48 + ); 49 + } 50 + 51 + function LeafletBlockRenderer({ 52 + block, 53 + authorDid, 54 + }: { 55 + block: LeafletLinearBlock; 56 + authorDid: string; 57 + }) { 58 + const alignment = 59 + block.alignment ? alignmentClasses[block.alignment] : undefined; 60 + return ( 61 + <div className={`flex w-full flex-col gap-2 ${alignment ?? "text-left"}`}> 62 + {renderLeafletBlock(block.block, authorDid)} 63 + </div> 64 + ); 65 + } 66 + 67 + function renderLeafletBlock(block: LeafletBlock, authorDid: string): React.ReactNode { 68 + switch (block.$type) { 69 + case "pub.leaflet.blocks.text": 70 + return ( 71 + <p className="leading-7"> 72 + <LeafletRichText plaintext={block.plaintext} facets={block.facets} /> 73 + </p> 74 + ); 75 + case "pub.leaflet.blocks.header": { 76 + const level = Math.min(Math.max(block.level ?? 1, 1), 6); 77 + const HeadingTag = (`h${level}` as unknown) as keyof JSX.IntrinsicElements; 78 + return ( 79 + <HeadingTag className="font-semibold tracking-tight"> 80 + <LeafletRichText plaintext={block.plaintext} facets={block.facets} /> 81 + </HeadingTag> 82 + ); 83 + } 84 + case "pub.leaflet.blocks.blockquote": 85 + return ( 86 + <blockquote className="border-l-2 border-slate-200 pl-4 italic text-slate-600 dark:border-slate-800 dark:text-slate-300"> 87 + <LeafletRichText plaintext={block.plaintext} facets={block.facets} /> 88 + </blockquote> 89 + ); 90 + case "pub.leaflet.blocks.horizontalRule": 91 + return <hr className="border-slate-800/10 dark:border-slate-100/10" />; 92 + case "pub.leaflet.blocks.image": { 93 + const src = blobRefToUrl(block.image, authorDid); 94 + if (!src) return null; 95 + const { width, height } = block.aspectRatio; 96 + const safeHeight = height || 1; 97 + return ( 98 + <div className="relative w-full" style={{ aspectRatio: `${width} / ${safeHeight}` }}> 99 + <Image 100 + src={src} 101 + alt={block.alt || ""} 102 + fill 103 + className="rounded-md object-contain" 104 + sizes="(max-width: 768px) 100vw, 600px" 105 + /> 106 + </div> 107 + ); 108 + } 109 + case "pub.leaflet.blocks.code": 110 + return ( 111 + <pre className="overflow-x-auto rounded-md bg-slate-900/5 p-4 text-sm font-mono"> 112 + {block.plaintext} 113 + </pre> 114 + ); 115 + case "pub.leaflet.blocks.math": 116 + return ( 117 + <pre className="overflow-x-auto rounded-md bg-slate-900/5 p-4 font-mono"> 118 + {block.tex} 119 + </pre> 120 + ); 121 + case "pub.leaflet.blocks.website": 122 + return ( 123 + <a 124 + href={block.src} 125 + target="_blank" 126 + rel="noreferrer noopener" 127 + className="rounded-md border border-slate-200 bg-slate-50 p-4 text-left transition hover:border-slate-400 dark:border-slate-800 dark:bg-slate-950" 128 + > 129 + <p className="text-sm font-semibold">{block.title || block.src}</p> 130 + {block.description && ( 131 + <p className="text-sm text-slate-600 dark:text-slate-400"> 132 + {block.description} 133 + </p> 134 + )} 135 + </a> 136 + ); 137 + case "pub.leaflet.blocks.iframe": 138 + return ( 139 + <iframe 140 + className="w-full rounded-md border border-slate-200" 141 + src={block.url} 142 + height={block.height ?? 480} 143 + allow="fullscreen" 144 + loading="lazy" 145 + /> 146 + ); 147 + case "pub.leaflet.blocks.button": 148 + return ( 149 + <a 150 + href={block.url} 151 + target="_blank" 152 + rel="noreferrer noopener" 153 + className="inline-flex items-center justify-center rounded-full bg-slate-900 px-6 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900" 154 + > 155 + {block.text} 156 + </a> 157 + ); 158 + case "pub.leaflet.blocks.unorderedList": 159 + return renderUnorderedList(block, authorDid); 160 + case "pub.leaflet.blocks.orderedList": 161 + return renderOrderedList(block, authorDid); 162 + case "pub.leaflet.blocks.page": 163 + return ( 164 + <p className="text-sm text-slate-500"> 165 + Linked page: <span className="font-mono">{block.id}</span> 166 + </p> 167 + ); 168 + case "pub.leaflet.blocks.bskyPost": 169 + return <BskyPostPreview block={block} />; 170 + case "pub.leaflet.blocks.poll": 171 + return ( 172 + <div className="rounded-md border border-slate-200 p-4 text-sm text-slate-500"> 173 + Poll block (not yet supported here). 174 + </div> 175 + ); 176 + default: { 177 + const _exhaustiveCheck: never = block; 178 + void _exhaustiveCheck; 179 + return ( 180 + <div className="rounded-md border border-dashed border-slate-200 p-4 text-sm text-slate-500"> 181 + Unsupported block type 182 + </div> 183 + ); 184 + } 185 + } 186 + } 187 + 188 + function renderUnorderedList(block: LeafletUnorderedListBlock, authorDid: string) { 189 + return ( 190 + <ul className="ml-6 list-disc space-y-2"> 191 + {block.children.map((child, index) => ( 192 + <LeafletListItemRenderer 193 + key={index} 194 + item={child} 195 + authorDid={authorDid} 196 + variant="unordered" 197 + /> 198 + ))} 199 + </ul> 200 + ); 201 + } 202 + 203 + function renderOrderedList(block: LeafletOrderedListBlock, authorDid: string) { 204 + return ( 205 + <ol 206 + className="ml-6 list-decimal space-y-2" 207 + start={typeof block.startIndex === "number" ? block.startIndex : undefined} 208 + > 209 + {block.children.map((child, index) => ( 210 + <LeafletListItemRenderer 211 + key={index} 212 + item={child} 213 + authorDid={authorDid} 214 + variant="ordered" 215 + /> 216 + ))} 217 + </ol> 218 + ); 219 + } 220 + 221 + function LeafletListItemRenderer({ 222 + item, 223 + authorDid, 224 + variant, 225 + }: { 226 + item: LeafletListItem; 227 + authorDid: string; 228 + variant: "ordered" | "unordered"; 229 + }) { 230 + return ( 231 + <li className="leading-7"> 232 + {renderLeafletBlock(item.content, authorDid)} 233 + {item.children?.length ? ( 234 + <div className="mt-2"> 235 + {variant === "ordered" 236 + ? renderOrderedList( 237 + { 238 + $type: "pub.leaflet.blocks.orderedList", 239 + children: item.children, 240 + } as LeafletOrderedListBlock, 241 + authorDid, 242 + ) 243 + : renderUnorderedList( 244 + { 245 + $type: "pub.leaflet.blocks.unorderedList", 246 + children: item.children, 247 + } as LeafletUnorderedListBlock, 248 + authorDid, 249 + )} 250 + </div> 251 + ) : null} 252 + </li> 253 + ); 254 + } 255 + 256 + function blobRefToUrl(blob: { ref?: { $link?: string } } | undefined, did: string) { 257 + const cid = blob?.ref?.$link; 258 + if (!cid) return null; 259 + return `https://cdn.bsky.app/img/feed_fullsize/plain/${encodeURIComponent(did)}/${cid}@jpeg`; 260 + } 261 + 262 + function BskyPostPreview({ block }: { block: LeafletBskyPostBlock }) { 263 + const uri = block.postRef.uri; 264 + const parsed = uri.split("/"); 265 + const did = parsed[2]; 266 + const rkey = parsed[parsed.length - 1]; 267 + const href = `https://bsky.app/profile/${did}/post/${rkey}`; 268 + return ( 269 + <Link 270 + href={href} 271 + target="_blank" 272 + className="rounded-md border border-slate-200 p-4 text-sm text-slate-600 hover:border-slate-400" 273 + > 274 + View Bluesky post 275 + </Link> 276 + ); 277 + }
+107
src/components/leaflet/rich-text.tsx
··· 1 + import { UnicodeString } from "@atproto/api"; 2 + 3 + import type { 4 + LeafletFacet, 5 + LeafletFacetFeature, 6 + } from "#/lib/leaflet/types"; 7 + 8 + type Segment = { 9 + text: string; 10 + features?: LeafletFacetFeature[]; 11 + }; 12 + 13 + export function LeafletRichText({ 14 + plaintext, 15 + facets, 16 + }: { 17 + plaintext: string; 18 + facets?: LeafletFacet[]; 19 + }) { 20 + const segments = buildSegments(plaintext, facets); 21 + 22 + return ( 23 + <> 24 + {segments.map((segment, index) => { 25 + const featureTypes = new Set(segment.features?.map((f) => f.$type)); 26 + const id = segment.features?.find((f) => f.$type.endsWith("#id")); 27 + const link = segment.features?.find((f) => f.$type.endsWith("#link")) as 28 + | ({ $type: string; uri?: string } & LeafletFacetFeature) 29 + | undefined; 30 + const className = [ 31 + featureTypes.has("pub.leaflet.richtext.facet#bold") && "font-semibold", 32 + featureTypes.has("pub.leaflet.richtext.facet#italic") && "italic", 33 + featureTypes.has("pub.leaflet.richtext.facet#underline") && "underline", 34 + featureTypes.has("pub.leaflet.richtext.facet#strikethrough") && 35 + "line-through text-slate-500", 36 + featureTypes.has("pub.leaflet.richtext.facet#highlight") && 37 + "bg-yellow-200 text-slate-900 px-1 rounded", 38 + ] 39 + .filter(Boolean) 40 + .join(" "); 41 + 42 + if (featureTypes.has("pub.leaflet.richtext.facet#code")) { 43 + return ( 44 + <code key={index} className={`rounded bg-slate-800/10 px-1 py-0.5 font-mono text-sm ${className}`} id={(id as { id?: string })?.id}> 45 + {segment.text} 46 + </code> 47 + ); 48 + } 49 + 50 + if (link?.uri) { 51 + return ( 52 + <a 53 + key={index} 54 + href={link.uri} 55 + target="_blank" 56 + rel="noreferrer noopener" 57 + className={`text-blue-600 underline-offset-2 hover:underline ${className}`} 58 + id={(id as { id?: string })?.id} 59 + > 60 + {segment.text} 61 + </a> 62 + ); 63 + } 64 + 65 + return ( 66 + <span key={index} className={className} id={(id as { id?: string })?.id}> 67 + {segment.text} 68 + </span> 69 + ); 70 + })} 71 + </> 72 + ); 73 + } 74 + 75 + function buildSegments(plaintext: string, facets?: LeafletFacet[]) { 76 + const unicode = new UnicodeString(plaintext ?? ""); 77 + if (!facets?.length) { 78 + return [{ text: unicode.utf16 }] as Segment[]; 79 + } 80 + 81 + const normalized = [...facets] 82 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 83 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 84 + const segments: Segment[] = []; 85 + 86 + let cursor = 0; 87 + for (const facet of normalized) { 88 + if (cursor < facet.index.byteStart) { 89 + segments.push({ 90 + text: unicode.slice(cursor, facet.index.byteStart), 91 + }); 92 + } 93 + if (facet.index.byteStart < facet.index.byteEnd) { 94 + segments.push({ 95 + text: unicode.slice(facet.index.byteStart, facet.index.byteEnd), 96 + features: facet.features, 97 + }); 98 + } 99 + cursor = facet.index.byteEnd; 100 + } 101 + 102 + if (cursor < unicode.length) { 103 + segments.push({ text: unicode.slice(cursor, unicode.length) }); 104 + } 105 + 106 + return segments; 107 + }
+57 -20
src/components/post-list.tsx
··· 1 1 import Link from "next/link"; 2 2 import { getPosts } from "#/lib/api"; 3 + import { extractLeafletPlaintext } from "#/lib/leaflet/plaintext"; 4 + import { getLeafletPosts } from "#/lib/leaflet/api"; 5 + import { LEAFLET_PUBLICATION_DID } from "#/lib/config"; 3 6 4 7 import { PostInfo } from "./post-info"; 5 8 import { Title } from "./typography"; 6 9 10 + type ListEntry = { 11 + key: string; 12 + href: string; 13 + title: string; 14 + content: string; 15 + createdAt?: string; 16 + }; 17 + 7 18 export async function PostList() { 8 - const posts = await getPosts(); 19 + const [whtwndPosts, leafletPosts] = await Promise.all([ 20 + getPosts(), 21 + LEAFLET_PUBLICATION_DID ? getLeafletPosts().catch(() => []) : Promise.resolve([]), 22 + ]); 23 + 24 + const entries: ListEntry[] = [ 25 + ...whtwndPosts.map((record) => { 26 + const post = record.value; 27 + const rkey = record.uri.split("/").pop(); 28 + return { 29 + key: record.uri, 30 + href: `/post/${rkey}`, 31 + title: post.title ?? "Untitled", 32 + content: post.content, 33 + createdAt: post.createdAt, 34 + } satisfies ListEntry; 35 + }), 36 + ...leafletPosts.map((record) => { 37 + const rkey = record.uri.split("/").pop(); 38 + return { 39 + key: record.uri, 40 + href: `/leaflet/${rkey}`, 41 + title: record.value.title ?? "Untitled", 42 + content: extractLeafletPlaintext(record.value), 43 + createdAt: record.value.publishedAt, 44 + } satisfies ListEntry; 45 + }), 46 + ]; 9 47 10 - return posts.map((record) => { 11 - const post = record.value; 12 - const rkey = record.uri.split("/").pop(); 13 - return ( 14 - <Link key={record.uri} href={`/post/${rkey}`} className="group w-full"> 15 - <article 16 - key={record.uri} 17 - className="relative flex w-full flex-row items-stretch border-b after:absolute after:inset-0 after:origin-bottom after:scale-y-0 after:bg-slate-800/10 after:transition-transform hover:after:scale-y-100 dark:after:bg-slate-100/10" 18 - > 19 - <div className="diagonal-pattern w-1.5 flex-shrink-0 opacity-20 transition-opacity group-hover:opacity-100" /> 20 - <div className="flex-1 px-4 pb-2 pt-2"> 21 - <Title className="text-lg" level="h3"> 22 - {post.title} 23 - </Title> 24 - <PostInfo content={post.content} createdAt={post.createdAt} /> 25 - </div> 26 - </article> 27 - </Link> 28 - ); 48 + entries.sort((a, b) => { 49 + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; 50 + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; 51 + return bTime - aTime; 29 52 }); 53 + 54 + return entries.map((entry) => ( 55 + <Link key={entry.key} href={entry.href} className="group w-full"> 56 + <article className="relative flex w-full flex-row items-stretch border-b after:absolute after:inset-0 after:origin-bottom after:scale-y-0 after:bg-slate-800/10 after:transition-transform hover:after:scale-y-100 dark:after:bg-slate-100/10"> 57 + <div className="diagonal-pattern w-1.5 flex-shrink-0 opacity-20 transition-opacity group-hover:opacity-100" /> 58 + <div className="flex-1 px-4 pb-2 pt-2"> 59 + <Title className="text-lg" level="h3"> 60 + {entry.title} 61 + </Title> 62 + <PostInfo content={entry.content} createdAt={entry.createdAt} /> 63 + </div> 64 + </article> 65 + </Link> 66 + )); 30 67 }
+3
src/lib/config.ts
··· 8 8 export const GITHUB_USERNAME = "aliceisjustplaying"; 9 9 export const CLOUDFLARE_BEACON_TOKEN = "80339998c1034152803ce2df95a47a56"; 10 10 export const PLAUSIBLE_DOMAIN = "plausible.bsky.sh"; 11 + 12 + export const LEAFLET_PUBLICATION_DID = MY_DID; 13 + export const LEAFLET_PDS = MY_PDS;
+68
src/lib/leaflet/api.ts
··· 1 + import { type XRPCResponse } from "@atcute/client"; 2 + import { 3 + type ComAtprotoRepoListRecords, 4 + type ComAtprotoRepoGetRecord, 5 + } from "@atcute/client/lexicons"; 6 + 7 + import { LEAFLET_PUBLICATION_DID } from "#/lib/config"; 8 + import { leafletBsky } from "#/lib/leaflet/client"; 9 + import type { LeafletDocumentRecord } from "#/lib/leaflet/types"; 10 + 11 + const COLLECTION_ID = "pub.leaflet.document"; 12 + 13 + type LeafletRecord = ComAtprotoRepoListRecords.Record & { 14 + value: LeafletDocumentRecord; 15 + }; 16 + 17 + export async function getLeafletPosts() { 18 + ensureLeafletConfig(); 19 + let cursor: string | undefined; 20 + let results: LeafletRecord[] = []; 21 + 22 + do { 23 + const page: XRPCResponse<ComAtprotoRepoListRecords.Output> = 24 + await leafletBsky.get("com.atproto.repo.listRecords", { 25 + params: { 26 + collection: COLLECTION_ID, 27 + cursor, 28 + repo: LEAFLET_PUBLICATION_DID!, 29 + limit: 100, 30 + }, 31 + }); 32 + 33 + const pageRecords = (page.data.records || []) 34 + .filter((record): record is LeafletRecord => 35 + Boolean( 36 + record.value && 37 + typeof record.value === "object" && 38 + (record.value as Record<string, unknown>).$type === COLLECTION_ID, 39 + ), 40 + ) 41 + .map((record) => record as LeafletRecord); 42 + 43 + results = results.concat(pageRecords); 44 + cursor = page.data.cursor; 45 + } while (cursor); 46 + 47 + return results; 48 + } 49 + 50 + export async function getLeafletPost(rkey: string) { 51 + ensureLeafletConfig(); 52 + const post: XRPCResponse<ComAtprotoRepoGetRecord.Output> = 53 + await leafletBsky.get("com.atproto.repo.getRecord", { 54 + params: { 55 + collection: COLLECTION_ID, 56 + repo: LEAFLET_PUBLICATION_DID!, 57 + rkey, 58 + }, 59 + }); 60 + 61 + return post.data as LeafletRecord; 62 + } 63 + 64 + function ensureLeafletConfig() { 65 + if (!LEAFLET_PUBLICATION_DID) { 66 + throw new Error("LEAFLET_PUBLICATION_DID is not configured"); 67 + } 68 + }
+10
src/lib/leaflet/client.ts
··· 1 + import { CredentialManager, XRPC } from "@atcute/client"; 2 + 3 + import { LEAFLET_PDS } from "#/lib/config"; 4 + 5 + const handler = new CredentialManager({ 6 + service: `https://${LEAFLET_PDS}`, 7 + fetch, 8 + }); 9 + 10 + export const leafletBsky = new XRPC({ handler });
+56
src/lib/leaflet/plaintext.ts
··· 1 + import type { 2 + LeafletBlock, 3 + LeafletDocumentRecord, 4 + LeafletLinearPage, 5 + LeafletListItem, 6 + } from "#/lib/leaflet/types"; 7 + 8 + export function extractLeafletPlaintext(record: LeafletDocumentRecord) { 9 + const pages = record.pages.filter( 10 + (page): page is LeafletLinearPage => 11 + page?.$type === "pub.leaflet.pages.linearDocument", 12 + ); 13 + const fragments: string[] = []; 14 + 15 + for (const page of pages) { 16 + for (const block of page.blocks) { 17 + collectBlockText(block.block, fragments); 18 + } 19 + } 20 + 21 + return fragments.join("\n\n"); 22 + } 23 + 24 + function collectBlockText(block: LeafletBlock, fragments: string[]) { 25 + switch (block.$type) { 26 + case "pub.leaflet.blocks.text": 27 + case "pub.leaflet.blocks.header": 28 + case "pub.leaflet.blocks.blockquote": 29 + fragments.push(block.plaintext); 30 + break; 31 + case "pub.leaflet.blocks.code": 32 + fragments.push(block.plaintext); 33 + break; 34 + case "pub.leaflet.blocks.math": 35 + fragments.push(block.tex); 36 + break; 37 + case "pub.leaflet.blocks.website": 38 + fragments.push(block.title || block.src); 39 + if (block.description) fragments.push(block.description); 40 + break; 41 + case "pub.leaflet.blocks.button": 42 + fragments.push(block.text); 43 + break; 44 + case "pub.leaflet.blocks.unorderedList": 45 + case "pub.leaflet.blocks.orderedList": 46 + block.children.forEach((child) => collectListItem(child, fragments)); 47 + break; 48 + default: 49 + break; 50 + } 51 + } 52 + 53 + function collectListItem(item: LeafletListItem, fragments: string[]) { 54 + collectBlockText(item.content, fragments); 55 + item.children?.forEach((child) => collectListItem(child, fragments)); 56 + }
+162
src/lib/leaflet/types.ts
··· 1 + export type LeafletDocumentRecord = { 2 + $type: "pub.leaflet.document"; 3 + title: string; 4 + description?: string; 5 + publishedAt?: string; 6 + publication: string; 7 + author: string; 8 + pages: LeafletPage[]; 9 + }; 10 + 11 + export type LeafletPage = LeafletLinearPage | LeafletCanvasPage; 12 + 13 + export type LeafletLinearPage = { 14 + $type: "pub.leaflet.pages.linearDocument"; 15 + id?: string; 16 + blocks: LeafletLinearBlock[]; 17 + }; 18 + 19 + export type LeafletLinearBlock = { 20 + $type: "pub.leaflet.pages.linearDocument#block"; 21 + alignment?: LeafletAlignment; 22 + block: LeafletBlock; 23 + }; 24 + 25 + export type LeafletAlignment = 26 + | "lex:pub.leaflet.pages.linearDocument#textAlignLeft" 27 + | "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 28 + | "lex:pub.leaflet.pages.linearDocument#textAlignRight" 29 + | "lex:pub.leaflet.pages.linearDocument#textAlignJustify"; 30 + 31 + export type LeafletBlock = 32 + | LeafletTextBlock 33 + | LeafletHeaderBlock 34 + | LeafletBlockquoteBlock 35 + | LeafletImageBlock 36 + | LeafletHorizontalRuleBlock 37 + | LeafletCodeBlock 38 + | LeafletMathBlock 39 + | LeafletWebsiteBlock 40 + | LeafletIframeBlock 41 + | LeafletButtonBlock 42 + | LeafletUnorderedListBlock 43 + | LeafletOrderedListBlock 44 + | LeafletPageLinkBlock 45 + | LeafletBskyPostBlock 46 + | LeafletPollBlock; 47 + 48 + export type LeafletFacet = { 49 + index: { byteStart: number; byteEnd: number }; 50 + features: Array<LeafletFacetFeature>; 51 + }; 52 + 53 + export type LeafletFacetFeature = { 54 + $type: string; 55 + [k: string]: unknown; 56 + }; 57 + 58 + export type LeafletBlobRef = { 59 + ref: { $link: string }; 60 + mimeType: string; 61 + size: number; 62 + }; 63 + 64 + export type LeafletTextBlock = { 65 + $type: "pub.leaflet.blocks.text"; 66 + plaintext: string; 67 + facets?: LeafletFacet[]; 68 + }; 69 + 70 + export type LeafletHeaderBlock = { 71 + $type: "pub.leaflet.blocks.header"; 72 + level?: number; 73 + plaintext: string; 74 + facets?: LeafletFacet[]; 75 + }; 76 + 77 + export type LeafletBlockquoteBlock = { 78 + $type: "pub.leaflet.blocks.blockquote"; 79 + plaintext: string; 80 + facets?: LeafletFacet[]; 81 + }; 82 + 83 + export type LeafletImageBlock = { 84 + $type: "pub.leaflet.blocks.image"; 85 + image: LeafletBlobRef; 86 + alt?: string; 87 + aspectRatio: { width: number; height: number }; 88 + }; 89 + 90 + export type LeafletHorizontalRuleBlock = { 91 + $type: "pub.leaflet.blocks.horizontalRule"; 92 + }; 93 + 94 + export type LeafletCodeBlock = { 95 + $type: "pub.leaflet.blocks.code"; 96 + plaintext: string; 97 + language?: string; 98 + syntaxHighlightingTheme?: string; 99 + }; 100 + 101 + export type LeafletMathBlock = { 102 + $type: "pub.leaflet.blocks.math"; 103 + tex: string; 104 + }; 105 + 106 + export type LeafletWebsiteBlock = { 107 + $type: "pub.leaflet.blocks.website"; 108 + previewImage?: LeafletBlobRef; 109 + title?: string; 110 + description?: string; 111 + src: string; 112 + }; 113 + 114 + export type LeafletIframeBlock = { 115 + $type: "pub.leaflet.blocks.iframe"; 116 + url: string; 117 + height?: number; 118 + }; 119 + 120 + export type LeafletButtonBlock = { 121 + $type: "pub.leaflet.blocks.button"; 122 + text: string; 123 + url: string; 124 + }; 125 + 126 + export type LeafletListItem = { 127 + $type: string; 128 + content: LeafletBlock; 129 + children?: LeafletListItem[]; 130 + }; 131 + 132 + export type LeafletUnorderedListBlock = { 133 + $type: "pub.leaflet.blocks.unorderedList"; 134 + children: LeafletListItem[]; 135 + }; 136 + 137 + export type LeafletOrderedListBlock = { 138 + $type: "pub.leaflet.blocks.orderedList"; 139 + children: LeafletListItem[]; 140 + startIndex?: number; 141 + }; 142 + 143 + export type LeafletPageLinkBlock = { 144 + $type: "pub.leaflet.blocks.page"; 145 + id: string; 146 + }; 147 + 148 + export type LeafletBskyPostBlock = { 149 + $type: "pub.leaflet.blocks.bskyPost"; 150 + postRef: { uri: string; cid: string }; 151 + }; 152 + 153 + export type LeafletPollBlock = { 154 + $type: "pub.leaflet.blocks.poll"; 155 + pollRef: { uri: string; cid: string }; 156 + }; 157 + 158 + export type LeafletCanvasPage = { 159 + $type: "pub.leaflet.pages.canvas"; 160 + id: string; 161 + blocks: unknown[]; 162 + };