A personal website powered by Astro and ATProto

Compare changes

Choose any two refs to compare.

+302
LEXICON_INTEGRATION.md
··· 1 + # Lexicon Integration Guide 2 + 3 + This guide explains how to add support for new ATproto lexicons in your Astro website. The system provides full type safety and automatic component routing. 4 + 5 + ## Overview 6 + 7 + The lexicon integration system consists of: 8 + 9 + 1. **Schema Files**: JSON lexicon definitions in `src/lexicons/` 10 + 2. **Type Generation**: Automatic TypeScript type generation from schemas 11 + 3. **Component Registry**: Type-safe mapping of lexicon types to Astro components 12 + 4. **Content Display**: Dynamic component routing based on record types 13 + 14 + ## Step-by-Step Guide 15 + 16 + ### 1. Add Lexicon Schema 17 + 18 + Create a JSON schema file in `src/lexicons/` following the ATproto lexicon specification: 19 + 20 + ```json 21 + // src/lexicons/com.example.myrecord.json 22 + { 23 + "lexicon": 1, 24 + "id": "com.example.myrecord", 25 + "description": "My custom record type", 26 + "defs": { 27 + "main": { 28 + "type": "record", 29 + "key": "tid", 30 + "record": { 31 + "type": "object", 32 + "required": ["title", "content"], 33 + "properties": { 34 + "title": { 35 + "type": "string", 36 + "description": "The title of the record" 37 + }, 38 + "content": { 39 + "type": "string", 40 + "description": "The content of the record" 41 + }, 42 + "tags": { 43 + "type": "array", 44 + "items": { 45 + "type": "string" 46 + }, 47 + "description": "Tags for the record" 48 + } 49 + } 50 + } 51 + } 52 + } 53 + } 54 + ``` 55 + 56 + ### 2. Update Site Configuration 57 + 58 + Add the lexicon to your site configuration in `src/lib/config/site.ts`: 59 + 60 + ```typescript 61 + export const defaultConfig: SiteConfig = { 62 + // ... existing config 63 + lexiconSources: { 64 + 'com.whtwnd.blog.entry': './src/lexicons/com.whtwnd.blog.entry.json', 65 + 'com.example.myrecord': './src/lexicons/com.example.myrecord.json', // Add your new lexicon 66 + }, 67 + }; 68 + ``` 69 + 70 + ### 3. Generate TypeScript Types 71 + 72 + Run the type generation script: 73 + 74 + ```bash 75 + npm run gen:types 76 + ``` 77 + 78 + This will create: 79 + - `src/lib/generated/com-example-myrecord.ts` - Individual type definitions 80 + - `src/lib/generated/lexicon-types.ts` - Union types and type maps 81 + 82 + ### 4. Create Your Component 83 + 84 + Create an Astro component to display your record type. **Components receive the typed record value directly**: 85 + 86 + ```astro 87 + --- 88 + // src/components/content/MyRecordDisplay.astro 89 + import type { ComExampleMyrecord } from '../../lib/generated/com-example-myrecord'; 90 + 91 + interface Props { 92 + record: ComExampleMyrecord['value']; // Typed record value, not generic AtprotoRecord 93 + showAuthor?: boolean; 94 + showTimestamp?: boolean; 95 + } 96 + 97 + const { record, showAuthor = true, showTimestamp = true } = Astro.props; 98 + 99 + // The record is already typed - no casting needed! 100 + --- 101 + 102 + <div class="my-record-display"> 103 + <h2 class="text-xl font-bold">{record.title}</h2> 104 + <p class="text-gray-600">{record.content}</p> 105 + 106 + {record.tags && record.tags.length > 0 && ( 107 + <div class="flex flex-wrap gap-2 mt-3"> 108 + {record.tags.map((tag: string) => ( 109 + <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm"> 110 + {tag} 111 + </span> 112 + ))} 113 + </div> 114 + )} 115 + </div> 116 + ``` 117 + 118 + ### 5. Register Your Component 119 + 120 + Add your component to the registry in `src/lib/components/registry.ts`: 121 + 122 + ```typescript 123 + export const registry: ComponentRegistry = { 124 + 'ComWhtwndBlogEntry': { 125 + component: 'WhitewindBlogPost', 126 + props: {} 127 + }, 128 + 'ComExampleMyrecord': { // Add your new type 129 + component: 'MyRecordDisplay', 130 + props: {} 131 + }, 132 + // ... other components 133 + }; 134 + ``` 135 + 136 + ### 6. Use Your Component 137 + 138 + Your component will now be automatically used when displaying records of your type: 139 + 140 + ```astro 141 + --- 142 + import ContentDisplay from '../../components/content/ContentDisplay.astro'; 143 + import type { AtprotoRecord } from '../../lib/atproto/atproto-browser'; 144 + 145 + const records: AtprotoRecord[] = await fetchRecords(); 146 + --- 147 + 148 + {records.map(record => ( 149 + <ContentDisplay record={record} showAuthor={true} showTimestamp={true} /> 150 + ))} 151 + ``` 152 + 153 + ## Type Safety Features 154 + 155 + ### Generated Types 156 + 157 + The system generates strongly typed interfaces: 158 + 159 + ```typescript 160 + // Generated from your schema 161 + export interface ComExampleMyrecordRecord { 162 + title: string; 163 + content: string; 164 + tags?: string[]; 165 + } 166 + 167 + export interface ComExampleMyrecord { 168 + $type: 'com.example.myrecord'; 169 + value: ComExampleMyrecordRecord; 170 + } 171 + ``` 172 + 173 + ### Direct Type Access 174 + 175 + Components receive the typed record value directly, not the generic `AtprotoRecord`: 176 + 177 + ```typescript 178 + // โœ… Good - Direct typed access 179 + interface Props { 180 + record: ComExampleMyrecord['value']; // Typed record value 181 + } 182 + 183 + // โŒ Avoid - Generic casting 184 + interface Props { 185 + record: AtprotoRecord; // Generic record 186 + } 187 + const myRecord = record.value as ComExampleMyrecord['value']; // Casting needed 188 + ``` 189 + 190 + ### Component Registry Types 191 + 192 + The registry provides type-safe component lookup: 193 + 194 + ```typescript 195 + // Type-safe component lookup 196 + const componentInfo = getComponentInfo('ComExampleMyrecord'); 197 + // componentInfo.component will be 'MyRecordDisplay' 198 + // componentInfo.props will be typed correctly 199 + ``` 200 + 201 + ### Automatic Fallbacks 202 + 203 + If no component is registered for a type, the system: 204 + 205 + 1. Tries to auto-assign a component name based on the NSID 206 + 2. Falls back to `GenericContentDisplay.astro` for unknown types 207 + 3. Shows debug information in development mode 208 + 209 + ## Advanced Usage 210 + 211 + ### Custom Props 212 + 213 + You can pass custom props to your components: 214 + 215 + ```typescript 216 + export const registry: ComponentRegistry = { 217 + 'ComExampleMyrecord': { 218 + component: 'MyRecordDisplay', 219 + props: { 220 + showTags: true, 221 + maxTags: 5 222 + } 223 + }, 224 + }; 225 + ``` 226 + 227 + ### Multiple Record Types 228 + 229 + Support multiple record types in one component: 230 + 231 + ```astro 232 + --- 233 + // Handle multiple types in one component 234 + const recordType = record?.$type; 235 + 236 + if (recordType === 'com.example.type1') { 237 + // Handle type 1 with typed access 238 + const type1Record = record as ComExampleType1['value']; 239 + } else if (recordType === 'com.example.type2') { 240 + // Handle type 2 with typed access 241 + const type2Record = record as ComExampleType2['value']; 242 + } 243 + --- 244 + ``` 245 + 246 + ### Dynamic Component Loading 247 + 248 + The system dynamically imports components and passes typed data: 249 + 250 + ```typescript 251 + // This happens automatically in ContentDisplay.astro 252 + const Component = await import(`../../components/content/${componentInfo.component}.astro`); 253 + // Component receives record.value (typed) instead of full AtprotoRecord 254 + ``` 255 + 256 + ## Troubleshooting 257 + 258 + ### Type Generation Issues 259 + 260 + If type generation fails: 261 + 262 + 1. Check your JSON schema syntax 263 + 2. Ensure the schema has a `main` record definition 264 + 3. Verify all required fields are properly defined 265 + 266 + ### Component Not Found 267 + 268 + If your component isn't being used: 269 + 270 + 1. Check the registry mapping in `src/lib/components/registry.ts` 271 + 2. Verify the component file exists in `src/components/content/` 272 + 3. Check the component name matches the registry entry 273 + 274 + ### Type Errors 275 + 276 + If you get TypeScript errors: 277 + 278 + 1. Regenerate types: `npm run gen:types` 279 + 2. Check that your component uses the correct generated types 280 + 3. Verify your component receives `RecordType['value']` not `AtprotoRecord` 281 + 282 + ## Best Practices 283 + 284 + 1. **Schema Design**: Follow ATproto lexicon conventions 285 + 2. **Type Safety**: Always use generated types in components 286 + 3. **Direct Access**: Components receive typed data directly, no casting needed 287 + 4. **Component Naming**: Use descriptive component names 288 + 5. **Error Handling**: Provide fallbacks for missing data 289 + 6. **Development**: Use debug mode to troubleshoot issues 290 + 291 + ## Example: Complete Integration 292 + 293 + Here's a complete example adding support for a photo gallery lexicon: 294 + 295 + 1. **Schema**: `src/lexicons/com.example.gallery.json` 296 + 2. **Config**: Add to `lexiconSources` 297 + 3. **Types**: Run `npm run gen:types` 298 + 4. **Component**: Create `GalleryDisplay.astro` with typed props 299 + 5. **Registry**: Add mapping in `registry.ts` 300 + 6. **Usage**: Use `ContentDisplay` component 301 + 302 + The system will automatically route gallery records to your `GalleryDisplay` component with full type safety and direct typed access.
+85 -48
README.md
··· 1 - # ATProto Personal Website 1 + # ATproto Personal Website 2 2 3 - A personal website powered by ATProto, featuring real-time streaming, repository browsing, and type generation. 3 + A personal website built with Astro that uses your ATproto repository as a CMS. Features full type safety, real-time updates, and automatic component routing. 4 4 5 5 ## Features 6 6 7 - ### ๐Ÿš€ Real-time Streaming 8 - - **Jetstream Test** (`/jetstream-test`): Real-time ATProto streaming with DID filtering 9 - - Uses the same jetstream endpoint as atptools for low-latency updates 10 - - Filters by your configured DID for personalized streaming 7 + - **Type-Safe Content**: Automatic TypeScript type generation from ATproto lexicon schemas 8 + - **Real-Time Updates**: Live content streaming via ATproto Jetstream 9 + - **Component Registry**: Type-safe mapping of lexicon types to Astro components 10 + - **Dynamic Routing**: Automatic component selection based on record types 11 + - **Blog Support**: Full blog post rendering with markdown support 12 + - **Gallery Display**: Image galleries with EXIF data and hover effects 13 + - **Pagination**: Fetch more than 100 records with cursor-based pagination 11 14 12 - ### ๐ŸŒ Repository Browsing 13 - - **ATProto Browser Test** (`/atproto-browser-test`): Browse any ATProto account's collections and records 14 - - Discover all collections in a repository 15 - - View records from specific collections 16 - - Similar functionality to atptools 15 + ## Quick Start 16 + 17 + 1. **Configure Environment**: 18 + ```bash 19 + cp env.example .env 20 + # Edit .env with your ATproto handle and DID 21 + ``` 22 + 23 + 2. **Install Dependencies**: 24 + ```bash 25 + npm install 26 + ``` 27 + 28 + 3. **Add Lexicon Schemas**: 29 + - Place JSON lexicon schemas in `src/lexicons/` 30 + - Update `src/lib/config/site.ts` with your lexicon sources 31 + - Run `npm run gen:types` to generate TypeScript types 17 32 18 - ### ๐Ÿ“ Type Generation 19 - - **Lexicon Generator Test** (`/lexicon-generator-test`): Generate TypeScript types for all lexicons in your repository 20 - - Automatically discovers all lexicon types from your configured account 21 - - Generates proper TypeScript interfaces and helper functions 22 - - Copy to clipboard or download as `.ts` file 33 + 4. **Create Components**: 34 + - Create Astro components in `src/components/content/` 35 + - Register them in `src/lib/components/registry.ts` 36 + - Use `ContentDisplay.astro` for automatic routing 23 37 24 - ### ๐Ÿ–ผ๏ธ Image Galleries 25 - - **Image Galleries** (`/galleries`): View grain.social image galleries and photo collections 38 + 5. **Start Development**: 39 + ```bash 40 + npm run dev 41 + ``` 26 42 27 - ## Configuration 43 + ## Lexicon Integration 28 44 29 - The site is configured to use your ATProto account: 45 + The system provides full type safety for ATproto lexicons: 30 46 31 - - **Handle**: `tynanpurdy.com` 32 - - **DID**: `did:plc:6ayddqghxhciedbaofoxkcbs` 33 - - **PDS**: `https://bsky.social` 47 + 1. **Schema Files**: JSON lexicon definitions in `src/lexicons/` 48 + 2. **Type Generation**: Automatic TypeScript type generation 49 + 3. **Component Registry**: Type-safe mapping of lexicon types to components 50 + 4. **Content Display**: Dynamic component routing 34 51 35 - ## Development 52 + See [LEXICON_INTEGRATION.md](./LEXICON_INTEGRATION.md) for detailed instructions. 36 53 37 - ```bash 38 - npm install 39 - npm run dev 40 - ``` 54 + ## Available Scripts 41 55 42 - Visit `http://localhost:4324` to see the site. 56 + - `npm run dev` - Start development server 57 + - `npm run build` - Build for production 58 + - `npm run preview` - Preview production build 59 + - `npm run discover` - Discover collections from your repo 60 + - `npm run gen:types` - Generate TypeScript types from lexicon schemas 43 61 44 62 ## Project Structure 45 63 46 64 ``` 47 65 src/ 48 - โ”œโ”€โ”€ lib/atproto/ 49 - โ”‚ โ”œโ”€โ”€ atproto-browser.ts # Repository browsing functionality 50 - โ”‚ โ”œโ”€โ”€ jetstream-client.ts # Real-time streaming client 51 - โ”‚ โ””โ”€โ”€ client.ts # Basic ATProto client 52 - โ”œโ”€โ”€ pages/ 53 - โ”‚ โ”œโ”€โ”€ index.astro # Homepage with navigation 54 - โ”‚ โ”œโ”€โ”€ jetstream-test.astro # Real-time streaming test 55 - โ”‚ โ”œโ”€โ”€ atproto-browser-test.astro # Repository browsing test 56 - โ”‚ โ”œโ”€โ”€ lexicon-generator-test.astro # Type generation test 57 - โ”‚ โ””โ”€โ”€ galleries.astro # Image galleries 58 - โ””โ”€โ”€ components/ 59 - โ””โ”€โ”€ content/ # Content display components 66 + โ”œโ”€โ”€ components/content/ # Content display components 67 + โ”œโ”€โ”€ lib/ 68 + โ”‚ โ”œโ”€โ”€ atproto/ # ATproto client and utilities 69 + โ”‚ โ”œโ”€โ”€ components/ # Component registry 70 + โ”‚ โ”œโ”€โ”€ config/ # Site configuration 71 + โ”‚ โ”œโ”€โ”€ generated/ # Generated TypeScript types 72 + โ”‚ โ”œโ”€โ”€ services/ # Content services 73 + โ”‚ โ””โ”€โ”€ types/ # Type definitions 74 + โ”œโ”€โ”€ lexicons/ # Lexicon schema files 75 + โ””โ”€โ”€ pages/ # Astro pages 60 76 ``` 61 77 62 - ## Technologies 78 + ## Configuration 63 79 64 - - **Astro**: Web framework 65 - - **ATProto API**: For repository access and streaming 66 - - **TypeScript**: For type safety 67 - - **Tailwind CSS**: For styling 80 + The system is configured via environment variables and `src/lib/config/site.ts`: 81 + 82 + - `ATPROTO_HANDLE` - Your Bluesky handle 83 + - `ATPROTO_DID` - Your DID (optional, auto-resolved) 84 + - `SITE_TITLE` - Site title 85 + - `SITE_DESCRIPTION` - Site description 86 + - `SITE_AUTHOR` - Site author 87 + 88 + ## Adding New Content Types 89 + 90 + 1. Create a lexicon schema in `src/lexicons/` 91 + 2. Add it to `lexiconSources` in site config 92 + 3. Run `npm run gen:types` 93 + 4. Create a component in `src/components/content/` 94 + 5. Register it in `src/lib/components/registry.ts` 95 + 96 + The system will automatically route records to your components with full type safety. 68 97 69 - ## Inspired By 98 + ## Development 70 99 71 - This project takes inspiration from [atptools](https://github.com/espeon/atptools) for repository browsing and jetstream streaming approaches. 100 + - Debug mode shows component routing information 101 + - Generic fallback for unknown record types 102 + - Real-time updates via Jetstream 103 + - Type-safe component registry 104 + - Automatic type generation from schemas 105 + 106 + ## License 107 + 108 + MIT
+494 -2
package-lock.json
··· 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 11 "@astrojs/check": "^0.9.4", 12 + "@atcute/jetstream": "^1.0.2", 12 13 "@atproto/api": "^0.16.2", 13 14 "@atproto/xrpc": "^0.7.1", 15 + "@nulfrost/leaflet-loader-astro": "^1.1.0", 14 16 "@tailwindcss/typography": "^0.5.16", 15 17 "@tailwindcss/vite": "^4.1.11", 16 18 "@types/node": "^24.2.0", 17 19 "astro": "^5.12.8", 18 20 "dotenv": "^17.2.1", 21 + "marked": "^12.0.2", 19 22 "tailwindcss": "^4.1.11", 23 + "tsx": "^4.19.2", 20 24 "typescript": "^5.9.2" 25 + }, 26 + "devDependencies": { 27 + "@atproto/lex-cli": "^0.9.1" 21 28 } 22 29 }, 23 30 "node_modules/@ampproject/remapping": { ··· 172 179 "yaml": "^2.5.0" 173 180 } 174 181 }, 182 + "node_modules/@atcute/client": { 183 + "version": "4.0.3", 184 + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.0.3.tgz", 185 + "integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==", 186 + "license": "MIT", 187 + "dependencies": { 188 + "@atcute/identity": "^1.0.2", 189 + "@atcute/lexicons": "^1.0.3" 190 + } 191 + }, 192 + "node_modules/@atcute/identity": { 193 + "version": "1.0.3", 194 + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.0.3.tgz", 195 + "integrity": "sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==", 196 + "license": "0BSD", 197 + "dependencies": { 198 + "@atcute/lexicons": "^1.0.4", 199 + "@badrap/valita": "^0.4.5" 200 + } 201 + }, 202 + "node_modules/@atcute/jetstream": { 203 + "version": "1.0.2", 204 + "resolved": "https://registry.npmjs.org/@atcute/jetstream/-/jetstream-1.0.2.tgz", 205 + "integrity": "sha512-ZtdNNxl4zq9cgUpXSL9F+AsXUZt0Zuyj0V7974D7LxdMxfTItPnMZ9dRG8GoFkkGz3+pszdsG888Ix8C0F2+mA==", 206 + "license": "MIT", 207 + "dependencies": { 208 + "@atcute/lexicons": "^1.0.2", 209 + "@badrap/valita": "^0.4.2", 210 + "@mary-ext/event-iterator": "^1.0.0", 211 + "@mary-ext/simple-event-emitter": "^1.0.0", 212 + "partysocket": "^1.1.4", 213 + "type-fest": "^4.41.0", 214 + "yocto-queue": "^1.2.1" 215 + } 216 + }, 217 + "node_modules/@atcute/lexicons": { 218 + "version": "1.1.0", 219 + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.1.0.tgz", 220 + "integrity": "sha512-LFqwnria78xLYb62Ri/+WwQpUTgZp2DuyolNGIIOV1dpiKhFFFh//nscHMA6IExFLQRqWDs3tTjy7zv0h3sf1Q==", 221 + "license": "0BSD", 222 + "dependencies": { 223 + "esm-env": "^1.2.2" 224 + } 225 + }, 175 226 "node_modules/@atproto/api": { 176 227 "version": "0.16.2", 177 228 "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.2.tgz", ··· 200 251 "zod": "^3.23.8" 201 252 } 202 253 }, 254 + "node_modules/@atproto/lex-cli": { 255 + "version": "0.9.1", 256 + "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.1.tgz", 257 + "integrity": "sha512-ftcUZd8rElHeUJq6pTcQkURnTEe7woCF4I1NK3j5GpT/itacEZtcppabjy5o2aUsbktZsALj3ch3xm7ZZ+Zp0w==", 258 + "dev": true, 259 + "license": "MIT", 260 + "dependencies": { 261 + "@atproto/lexicon": "^0.4.12", 262 + "@atproto/syntax": "^0.4.0", 263 + "chalk": "^4.1.2", 264 + "commander": "^9.4.0", 265 + "prettier": "^3.2.5", 266 + "ts-morph": "^24.0.0", 267 + "yesno": "^0.4.0", 268 + "zod": "^3.23.8" 269 + }, 270 + "bin": { 271 + "lex": "dist/index.js" 272 + }, 273 + "engines": { 274 + "node": ">=18.7.0" 275 + } 276 + }, 277 + "node_modules/@atproto/lex-cli/node_modules/ansi-styles": { 278 + "version": "4.3.0", 279 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 280 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 281 + "dev": true, 282 + "license": "MIT", 283 + "dependencies": { 284 + "color-convert": "^2.0.1" 285 + }, 286 + "engines": { 287 + "node": ">=8" 288 + }, 289 + "funding": { 290 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 291 + } 292 + }, 293 + "node_modules/@atproto/lex-cli/node_modules/chalk": { 294 + "version": "4.1.2", 295 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 296 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 297 + "dev": true, 298 + "license": "MIT", 299 + "dependencies": { 300 + "ansi-styles": "^4.1.0", 301 + "supports-color": "^7.1.0" 302 + }, 303 + "engines": { 304 + "node": ">=10" 305 + }, 306 + "funding": { 307 + "url": "https://github.com/chalk/chalk?sponsor=1" 308 + } 309 + }, 203 310 "node_modules/@atproto/lexicon": { 204 311 "version": "0.4.12", 205 312 "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.12.tgz", ··· 273 380 }, 274 381 "engines": { 275 382 "node": ">=6.9.0" 383 + } 384 + }, 385 + "node_modules/@badrap/valita": { 386 + "version": "0.4.6", 387 + "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz", 388 + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==", 389 + "license": "MIT", 390 + "engines": { 391 + "node": ">= 18" 276 392 } 277 393 }, 278 394 "node_modules/@capsizecss/unpack": { ··· 1175 1291 "@jridgewell/sourcemap-codec": "^1.4.14" 1176 1292 } 1177 1293 }, 1294 + "node_modules/@mary-ext/event-iterator": { 1295 + "version": "1.0.0", 1296 + "resolved": "https://registry.npmjs.org/@mary-ext/event-iterator/-/event-iterator-1.0.0.tgz", 1297 + "integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==", 1298 + "license": "BSD-3-Clause", 1299 + "dependencies": { 1300 + "yocto-queue": "^1.2.1" 1301 + } 1302 + }, 1303 + "node_modules/@mary-ext/simple-event-emitter": { 1304 + "version": "1.0.0", 1305 + "resolved": "https://registry.npmjs.org/@mary-ext/simple-event-emitter/-/simple-event-emitter-1.0.0.tgz", 1306 + "integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==", 1307 + "license": "BSD-3-Clause" 1308 + }, 1178 1309 "node_modules/@nodelib/fs.scandir": { 1179 1310 "version": "2.1.5", 1180 1311 "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", ··· 1208 1339 }, 1209 1340 "engines": { 1210 1341 "node": ">= 8" 1342 + } 1343 + }, 1344 + "node_modules/@nulfrost/leaflet-loader-astro": { 1345 + "version": "1.1.0", 1346 + "resolved": "https://registry.npmjs.org/@nulfrost/leaflet-loader-astro/-/leaflet-loader-astro-1.1.0.tgz", 1347 + "integrity": "sha512-A6ONOmds3/3pVFfa+YdpC5YMfOF1shvczAOnSWfVtUYz3bl3NRz26KieUrGW+26iVPgUtHPRLQOfygImrLrhYw==", 1348 + "license": "MIT", 1349 + "dependencies": { 1350 + "@atcute/client": "^4.0.3", 1351 + "@atcute/lexicons": "^1.1.0", 1352 + "@atproto/api": "^0.16.2", 1353 + "katex": "^0.16.22", 1354 + "sanitize-html": "^2.17.0" 1211 1355 } 1212 1356 }, 1213 1357 "node_modules/@oslojs/encoding": { ··· 1857 2001 "vite": "^5.2.0 || ^6 || ^7" 1858 2002 } 1859 2003 }, 2004 + "node_modules/@ts-morph/common": { 2005 + "version": "0.25.0", 2006 + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", 2007 + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", 2008 + "dev": true, 2009 + "license": "MIT", 2010 + "dependencies": { 2011 + "minimatch": "^9.0.4", 2012 + "path-browserify": "^1.0.1", 2013 + "tinyglobby": "^0.2.9" 2014 + } 2015 + }, 1860 2016 "node_modules/@types/debug": { 1861 2017 "version": "4.1.12", 1862 2018 "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", ··· 2287 2443 "url": "https://github.com/sponsors/wooorm" 2288 2444 } 2289 2445 }, 2446 + "node_modules/balanced-match": { 2447 + "version": "1.0.2", 2448 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 2449 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 2450 + "dev": true, 2451 + "license": "MIT" 2452 + }, 2290 2453 "node_modules/base-64": { 2291 2454 "version": "1.0.0", 2292 2455 "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", ··· 2353 2516 }, 2354 2517 "funding": { 2355 2518 "url": "https://github.com/sponsors/sindresorhus" 2519 + } 2520 + }, 2521 + "node_modules/brace-expansion": { 2522 + "version": "2.0.2", 2523 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", 2524 + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", 2525 + "dev": true, 2526 + "license": "MIT", 2527 + "dependencies": { 2528 + "balanced-match": "^1.0.0" 2356 2529 } 2357 2530 }, 2358 2531 "node_modules/braces": { ··· 2596 2769 "node": ">=6" 2597 2770 } 2598 2771 }, 2772 + "node_modules/code-block-writer": { 2773 + "version": "13.0.3", 2774 + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 2775 + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 2776 + "dev": true, 2777 + "license": "MIT" 2778 + }, 2599 2779 "node_modules/color": { 2600 2780 "version": "4.2.3", 2601 2781 "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", ··· 2649 2829 "url": "https://github.com/sponsors/wooorm" 2650 2830 } 2651 2831 }, 2832 + "node_modules/commander": { 2833 + "version": "9.5.0", 2834 + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", 2835 + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", 2836 + "dev": true, 2837 + "license": "MIT", 2838 + "engines": { 2839 + "node": "^12.20.0 || >=14" 2840 + } 2841 + }, 2652 2842 "node_modules/common-ancestor-path": { 2653 2843 "version": "1.0.1", 2654 2844 "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", ··· 2743 2933 "url": "https://github.com/sponsors/wooorm" 2744 2934 } 2745 2935 }, 2936 + "node_modules/deepmerge": { 2937 + "version": "4.3.1", 2938 + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", 2939 + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", 2940 + "license": "MIT", 2941 + "engines": { 2942 + "node": ">=0.10.0" 2943 + } 2944 + }, 2746 2945 "node_modules/defu": { 2747 2946 "version": "6.1.4", 2748 2947 "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", ··· 2825 3024 "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", 2826 3025 "license": "MIT" 2827 3026 }, 3027 + "node_modules/dom-serializer": { 3028 + "version": "2.0.0", 3029 + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", 3030 + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 3031 + "license": "MIT", 3032 + "dependencies": { 3033 + "domelementtype": "^2.3.0", 3034 + "domhandler": "^5.0.2", 3035 + "entities": "^4.2.0" 3036 + }, 3037 + "funding": { 3038 + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" 3039 + } 3040 + }, 3041 + "node_modules/dom-serializer/node_modules/entities": { 3042 + "version": "4.5.0", 3043 + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", 3044 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 3045 + "license": "BSD-2-Clause", 3046 + "engines": { 3047 + "node": ">=0.12" 3048 + }, 3049 + "funding": { 3050 + "url": "https://github.com/fb55/entities?sponsor=1" 3051 + } 3052 + }, 3053 + "node_modules/domelementtype": { 3054 + "version": "2.3.0", 3055 + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", 3056 + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", 3057 + "funding": [ 3058 + { 3059 + "type": "github", 3060 + "url": "https://github.com/sponsors/fb55" 3061 + } 3062 + ], 3063 + "license": "BSD-2-Clause" 3064 + }, 3065 + "node_modules/domhandler": { 3066 + "version": "5.0.3", 3067 + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", 3068 + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 3069 + "license": "BSD-2-Clause", 3070 + "dependencies": { 3071 + "domelementtype": "^2.3.0" 3072 + }, 3073 + "engines": { 3074 + "node": ">= 4" 3075 + }, 3076 + "funding": { 3077 + "url": "https://github.com/fb55/domhandler?sponsor=1" 3078 + } 3079 + }, 3080 + "node_modules/domutils": { 3081 + "version": "3.2.2", 3082 + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", 3083 + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 3084 + "license": "BSD-2-Clause", 3085 + "dependencies": { 3086 + "dom-serializer": "^2.0.0", 3087 + "domelementtype": "^2.3.0", 3088 + "domhandler": "^5.0.3" 3089 + }, 3090 + "funding": { 3091 + "url": "https://github.com/fb55/domutils?sponsor=1" 3092 + } 3093 + }, 2828 3094 "node_modules/dotenv": { 2829 3095 "version": "17.2.1", 2830 3096 "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", ··· 2960 3226 "funding": { 2961 3227 "url": "https://github.com/sponsors/sindresorhus" 2962 3228 } 3229 + }, 3230 + "node_modules/esm-env": { 3231 + "version": "1.2.2", 3232 + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", 3233 + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", 3234 + "license": "MIT" 2963 3235 }, 2964 3236 "node_modules/estree-walker": { 2965 3237 "version": "3.0.3", ··· 2970 3242 "@types/estree": "^1.0.0" 2971 3243 } 2972 3244 }, 3245 + "node_modules/event-target-polyfill": { 3246 + "version": "0.0.4", 3247 + "resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz", 3248 + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==", 3249 + "license": "MIT" 3250 + }, 2973 3251 "node_modules/eventemitter3": { 2974 3252 "version": "5.0.1", 2975 3253 "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", ··· 3126 3404 "url": "https://github.com/sponsors/sindresorhus" 3127 3405 } 3128 3406 }, 3407 + "node_modules/get-tsconfig": { 3408 + "version": "4.10.1", 3409 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", 3410 + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", 3411 + "license": "MIT", 3412 + "dependencies": { 3413 + "resolve-pkg-maps": "^1.0.0" 3414 + }, 3415 + "funding": { 3416 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 3417 + } 3418 + }, 3129 3419 "node_modules/github-slugger": { 3130 3420 "version": "2.0.0", 3131 3421 "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", ··· 3171 3461 "radix3": "^1.1.2", 3172 3462 "ufo": "^1.6.1", 3173 3463 "uncrypto": "^0.1.3" 3464 + } 3465 + }, 3466 + "node_modules/has-flag": { 3467 + "version": "4.0.0", 3468 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 3469 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 3470 + "dev": true, 3471 + "license": "MIT", 3472 + "engines": { 3473 + "node": ">=8" 3174 3474 } 3175 3475 }, 3176 3476 "node_modules/hast-util-from-html": { ··· 3376 3676 "url": "https://github.com/sponsors/wooorm" 3377 3677 } 3378 3678 }, 3679 + "node_modules/htmlparser2": { 3680 + "version": "8.0.2", 3681 + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", 3682 + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", 3683 + "funding": [ 3684 + "https://github.com/fb55/htmlparser2?sponsor=1", 3685 + { 3686 + "type": "github", 3687 + "url": "https://github.com/sponsors/fb55" 3688 + } 3689 + ], 3690 + "license": "MIT", 3691 + "dependencies": { 3692 + "domelementtype": "^2.3.0", 3693 + "domhandler": "^5.0.3", 3694 + "domutils": "^3.0.1", 3695 + "entities": "^4.4.0" 3696 + } 3697 + }, 3698 + "node_modules/htmlparser2/node_modules/entities": { 3699 + "version": "4.5.0", 3700 + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", 3701 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 3702 + "license": "BSD-2-Clause", 3703 + "engines": { 3704 + "node": ">=0.12" 3705 + }, 3706 + "funding": { 3707 + "url": "https://github.com/fb55/entities?sponsor=1" 3708 + } 3709 + }, 3379 3710 "node_modules/http-cache-semantics": { 3380 3711 "version": "4.2.0", 3381 3712 "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", ··· 3492 3823 "url": "https://github.com/sponsors/sindresorhus" 3493 3824 } 3494 3825 }, 3826 + "node_modules/is-plain-object": { 3827 + "version": "5.0.0", 3828 + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", 3829 + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", 3830 + "license": "MIT", 3831 + "engines": { 3832 + "node": ">=0.10.0" 3833 + } 3834 + }, 3495 3835 "node_modules/is-wsl": { 3496 3836 "version": "3.1.0", 3497 3837 "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", ··· 3545 3885 "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", 3546 3886 "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", 3547 3887 "license": "MIT" 3888 + }, 3889 + "node_modules/katex": { 3890 + "version": "0.16.22", 3891 + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", 3892 + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", 3893 + "funding": [ 3894 + "https://opencollective.com/katex", 3895 + "https://github.com/sponsors/katex" 3896 + ], 3897 + "license": "MIT", 3898 + "dependencies": { 3899 + "commander": "^8.3.0" 3900 + }, 3901 + "bin": { 3902 + "katex": "cli.js" 3903 + } 3904 + }, 3905 + "node_modules/katex/node_modules/commander": { 3906 + "version": "8.3.0", 3907 + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", 3908 + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", 3909 + "license": "MIT", 3910 + "engines": { 3911 + "node": ">= 12" 3912 + } 3548 3913 }, 3549 3914 "node_modules/kleur": { 3550 3915 "version": "4.1.5", ··· 3853 4218 "url": "https://github.com/sponsors/wooorm" 3854 4219 } 3855 4220 }, 4221 + "node_modules/marked": { 4222 + "version": "12.0.2", 4223 + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", 4224 + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", 4225 + "license": "MIT", 4226 + "bin": { 4227 + "marked": "bin/marked.js" 4228 + }, 4229 + "engines": { 4230 + "node": ">= 18" 4231 + } 4232 + }, 3856 4233 "node_modules/mdast-util-definitions": { 3857 4234 "version": "6.0.0", 3858 4235 "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", ··· 4681 5058 "url": "https://github.com/sponsors/jonschlinkert" 4682 5059 } 4683 5060 }, 5061 + "node_modules/minimatch": { 5062 + "version": "9.0.5", 5063 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 5064 + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 5065 + "dev": true, 5066 + "license": "ISC", 5067 + "dependencies": { 5068 + "brace-expansion": "^2.0.1" 5069 + }, 5070 + "engines": { 5071 + "node": ">=16 || 14 >=14.17" 5072 + }, 5073 + "funding": { 5074 + "url": "https://github.com/sponsors/isaacs" 5075 + } 5076 + }, 4684 5077 "node_modules/minipass": { 4685 5078 "version": "7.1.2", 4686 5079 "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", ··· 4932 5325 "url": "https://github.com/sponsors/wooorm" 4933 5326 } 4934 5327 }, 5328 + "node_modules/parse-srcset": { 5329 + "version": "1.0.2", 5330 + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", 5331 + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", 5332 + "license": "MIT" 5333 + }, 4935 5334 "node_modules/parse5": { 4936 5335 "version": "7.3.0", 4937 5336 "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", ··· 4944 5343 "url": "https://github.com/inikulin/parse5?sponsor=1" 4945 5344 } 4946 5345 }, 5346 + "node_modules/partysocket": { 5347 + "version": "1.1.5", 5348 + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.1.5.tgz", 5349 + "integrity": "sha512-8uw9foq9bij4sKLCtTSHvyqMrMTQ5FJjrHc7BjoM2s95Vu7xYCN63ABpI7OZHC7ZMP5xaom/A+SsoFPXmTV6ZQ==", 5350 + "license": "MIT", 5351 + "dependencies": { 5352 + "event-target-polyfill": "^0.0.4" 5353 + } 5354 + }, 4947 5355 "node_modules/path-browserify": { 4948 5356 "version": "1.0.1", 4949 5357 "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", ··· 5013 5421 "version": "3.6.2", 5014 5422 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", 5015 5423 "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", 5424 + "devOptional": true, 5016 5425 "license": "MIT", 5017 - "optional": true, 5018 - "peer": true, 5019 5426 "bin": { 5020 5427 "prettier": "bin/prettier.cjs" 5021 5428 }, ··· 5296 5703 "node": ">=0.10.0" 5297 5704 } 5298 5705 }, 5706 + "node_modules/resolve-pkg-maps": { 5707 + "version": "1.0.0", 5708 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 5709 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 5710 + "license": "MIT", 5711 + "funding": { 5712 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 5713 + } 5714 + }, 5299 5715 "node_modules/restructure": { 5300 5716 "version": "3.0.2", 5301 5717 "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", ··· 5435 5851 "queue-microtask": "^1.2.2" 5436 5852 } 5437 5853 }, 5854 + "node_modules/sanitize-html": { 5855 + "version": "2.17.0", 5856 + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", 5857 + "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", 5858 + "license": "MIT", 5859 + "dependencies": { 5860 + "deepmerge": "^4.2.2", 5861 + "escape-string-regexp": "^4.0.0", 5862 + "htmlparser2": "^8.0.0", 5863 + "is-plain-object": "^5.0.0", 5864 + "parse-srcset": "^1.0.2", 5865 + "postcss": "^8.3.11" 5866 + } 5867 + }, 5868 + "node_modules/sanitize-html/node_modules/escape-string-regexp": { 5869 + "version": "4.0.0", 5870 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", 5871 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 5872 + "license": "MIT", 5873 + "engines": { 5874 + "node": ">=10" 5875 + }, 5876 + "funding": { 5877 + "url": "https://github.com/sponsors/sindresorhus" 5878 + } 5879 + }, 5438 5880 "node_modules/semver": { 5439 5881 "version": "7.7.2", 5440 5882 "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", ··· 5596 6038 "url": "https://github.com/chalk/strip-ansi?sponsor=1" 5597 6039 } 5598 6040 }, 6041 + "node_modules/supports-color": { 6042 + "version": "7.2.0", 6043 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 6044 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 6045 + "dev": true, 6046 + "license": "MIT", 6047 + "dependencies": { 6048 + "has-flag": "^4.0.0" 6049 + }, 6050 + "engines": { 6051 + "node": ">=8" 6052 + } 6053 + }, 5599 6054 "node_modules/tailwindcss": { 5600 6055 "version": "4.1.11", 5601 6056 "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", ··· 5703 6158 "url": "https://github.com/sponsors/wooorm" 5704 6159 } 5705 6160 }, 6161 + "node_modules/ts-morph": { 6162 + "version": "24.0.0", 6163 + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", 6164 + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", 6165 + "dev": true, 6166 + "license": "MIT", 6167 + "dependencies": { 6168 + "@ts-morph/common": "~0.25.0", 6169 + "code-block-writer": "^13.0.3" 6170 + } 6171 + }, 5706 6172 "node_modules/tsconfck": { 5707 6173 "version": "3.1.6", 5708 6174 "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", ··· 5728 6194 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 5729 6195 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 5730 6196 "license": "0BSD" 6197 + }, 6198 + "node_modules/tsx": { 6199 + "version": "4.20.3", 6200 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", 6201 + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", 6202 + "license": "MIT", 6203 + "dependencies": { 6204 + "esbuild": "~0.25.0", 6205 + "get-tsconfig": "^4.7.5" 6206 + }, 6207 + "bin": { 6208 + "tsx": "dist/cli.mjs" 6209 + }, 6210 + "engines": { 6211 + "node": ">=18.0.0" 6212 + }, 6213 + "optionalDependencies": { 6214 + "fsevents": "~2.3.3" 6215 + } 5731 6216 }, 5732 6217 "node_modules/type-fest": { 5733 6218 "version": "4.41.0", ··· 6707 7192 "engines": { 6708 7193 "node": ">=8" 6709 7194 } 7195 + }, 7196 + "node_modules/yesno": { 7197 + "version": "0.4.0", 7198 + "resolved": "https://registry.npmjs.org/yesno/-/yesno-0.4.0.tgz", 7199 + "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==", 7200 + "dev": true, 7201 + "license": "BSD" 6710 7202 }, 6711 7203 "node_modules/yocto-queue": { 6712 7204 "version": "1.2.1",
+9 -1
package.json
··· 6 6 "dev": "astro dev", 7 7 "build": "astro build", 8 8 "preview": "astro preview", 9 - "astro": "astro" 9 + "astro": "astro", 10 + "discover": "tsx scripts/discover-collections.ts", 11 + "gen:types": "tsx scripts/generate-types.ts" 10 12 }, 11 13 "dependencies": { 12 14 "@astrojs/check": "^0.9.4", 15 + "@atcute/jetstream": "^1.0.2", 13 16 "@atproto/api": "^0.16.2", 14 17 "@atproto/xrpc": "^0.7.1", 18 + "@nulfrost/leaflet-loader-astro": "^1.1.0", 15 19 "@tailwindcss/typography": "^0.5.16", 16 20 "@tailwindcss/vite": "^4.1.11", 17 21 "@types/node": "^24.2.0", 18 22 "astro": "^5.12.8", 19 23 "dotenv": "^17.2.1", 24 + "marked": "^12.0.2", 20 25 "tailwindcss": "^4.1.11", 21 26 "tsx": "^4.19.2", 22 27 "typescript": "^5.9.2" 28 + }, 29 + "devDependencies": { 30 + "@atproto/lex-cli": "^0.9.1" 23 31 } 24 32 }
+162
scripts/generate-types.ts
··· 1 + #!/usr/bin/env node 2 + 3 + import { readdir, readFile, writeFile } from 'fs/promises'; 4 + import { join } from 'path'; 5 + import { loadConfig } from '../src/lib/config/site'; 6 + 7 + interface LexiconSchema { 8 + lexicon: number; 9 + id: string; 10 + description?: string; 11 + defs: Record<string, any>; 12 + } 13 + 14 + function generateTypeScriptTypes(schema: LexiconSchema): string { 15 + const nsid = schema.id; 16 + const typeName = nsid.split('.').map(part => 17 + part.charAt(0).toUpperCase() + part.slice(1) 18 + ).join(''); 19 + 20 + const mainDef = schema.defs.main; 21 + if (!mainDef || mainDef.type !== 'record') { 22 + throw new Error(`Schema ${nsid} must have a 'main' record definition`); 23 + } 24 + 25 + const recordSchema = mainDef.record; 26 + const properties = recordSchema.properties || {}; 27 + const required = recordSchema.required || []; 28 + 29 + // Generate property types 30 + const propertyTypes: string[] = []; 31 + 32 + for (const [propName, propSchema] of Object.entries(properties)) { 33 + const isRequired = required.includes(propName); 34 + const optional = isRequired ? '' : '?'; 35 + 36 + let type: string; 37 + 38 + switch (propSchema.type) { 39 + case 'string': 40 + if (propSchema.enum) { 41 + type = propSchema.enum.map((v: string) => `'${v}'`).join(' | '); 42 + } else { 43 + type = 'string'; 44 + } 45 + break; 46 + case 'integer': 47 + type = 'number'; 48 + break; 49 + case 'boolean': 50 + type = 'boolean'; 51 + break; 52 + case 'array': 53 + type = 'any[]'; // Could be more specific based on items schema 54 + break; 55 + case 'object': 56 + type = 'Record<string, any>'; 57 + break; 58 + default: 59 + type = 'any'; 60 + } 61 + 62 + propertyTypes.push(` ${propName}${optional}: ${type};`); 63 + } 64 + 65 + return `// Generated from lexicon schema: ${nsid} 66 + // Do not edit manually - regenerate with: npm run gen:types 67 + 68 + export interface ${typeName}Record { 69 + ${propertyTypes.join('\n')} 70 + } 71 + 72 + export interface ${typeName} { 73 + $type: '${nsid}'; 74 + value: ${typeName}Record; 75 + } 76 + 77 + // Helper type for discriminated unions 78 + export type ${typeName}Union = ${typeName}; 79 + `; 80 + } 81 + 82 + async function generateTypes() { 83 + const config = loadConfig(); 84 + const lexiconsDir = join(process.cwd(), 'src/lexicons'); 85 + const generatedDir = join(process.cwd(), 'src/lib/generated'); 86 + 87 + console.log('๐Ÿ” Scanning for lexicon schemas...'); 88 + 89 + try { 90 + const files = await readdir(lexiconsDir); 91 + const jsonFiles = files.filter(file => file.endsWith('.json')); 92 + 93 + if (jsonFiles.length === 0) { 94 + console.log('No lexicon schema files found in src/lexicons/'); 95 + return; 96 + } 97 + 98 + console.log(`Found ${jsonFiles.length} lexicon schema(s):`); 99 + 100 + const generatedTypes: string[] = []; 101 + const unionTypes: string[] = []; 102 + 103 + for (const file of jsonFiles) { 104 + const schemaPath = join(lexiconsDir, file); 105 + const schemaContent = await readFile(schemaPath, 'utf-8'); 106 + const schema: LexiconSchema = JSON.parse(schemaContent); 107 + 108 + console.log(` - ${schema.id} (${file})`); 109 + 110 + try { 111 + const typesCode = generateTypeScriptTypes(schema); 112 + const outputPath = join(generatedDir, `${schema.id.replace(/\./g, '-')}.ts`); 113 + 114 + await writeFile(outputPath, typesCode, 'utf-8'); 115 + console.log(` โœ… Generated types: ${outputPath}`); 116 + 117 + // Add to union types 118 + const typeName = schema.id.split('.').map(part => 119 + part.charAt(0).toUpperCase() + part.slice(1) 120 + ).join(''); 121 + unionTypes.push(typeName); 122 + generatedTypes.push(`import type { ${typeName} } from './${schema.id.replace(/\./g, '-')}';`); 123 + 124 + } catch (error) { 125 + console.error(` โŒ Failed to generate types for ${schema.id}:`, error); 126 + } 127 + } 128 + 129 + // Generate index file with union types 130 + if (generatedTypes.length > 0) { 131 + const indexContent = `// Generated index of all lexicon types 132 + // Do not edit manually - regenerate with: npm run gen:types 133 + 134 + ${generatedTypes.join('\n')} 135 + 136 + // Union type for all generated lexicon records 137 + export type GeneratedLexiconUnion = ${unionTypes.join(' | ')}; 138 + 139 + // Type map for component registry 140 + export type GeneratedLexiconTypeMap = { 141 + ${unionTypes.map(type => ` '${type}': ${type};`).join('\n')} 142 + }; 143 + `; 144 + 145 + const indexPath = join(generatedDir, 'lexicon-types.ts'); 146 + await writeFile(indexPath, indexContent, 'utf-8'); 147 + console.log(` โœ… Generated index: ${indexPath}`); 148 + } 149 + 150 + console.log('\n๐ŸŽ‰ Type generation complete!'); 151 + console.log('\nNext steps:'); 152 + console.log('1. Import the generated types in your components'); 153 + console.log('2. Update the component registry with the new types'); 154 + console.log('3. Create components that use the strongly typed records'); 155 + 156 + } catch (error) { 157 + console.error('Error generating types:', error); 158 + process.exit(1); 159 + } 160 + } 161 + 162 + generateTypes();
+1 -3
src/components/content/BlueskyFeed.astro
··· 6 6 interface Props { 7 7 feedUri: string; 8 8 limit?: number; 9 - showAuthor?: boolean; 10 9 showTimestamp?: boolean; 11 10 } 12 11 13 - const { feedUri, limit = 10, showAuthor = true, showTimestamp = true } = Astro.props; 12 + const { feedUri, limit = 10, showTimestamp = true } = Astro.props; 14 13 15 14 const config = loadConfig(); 16 15 const browser = new AtprotoBrowser(); ··· 34 33 blueskyPosts.map((record) => ( 35 34 <BlueskyPost 36 35 post={record.value} 37 - showAuthor={showAuthor} 38 36 showTimestamp={showTimestamp} 39 37 /> 40 38 ))
+7 -22
src/components/content/BlueskyPost.astro
··· 1 1 --- 2 - import type { BlueskyPost } from '../../lib/types/atproto'; 2 + import type { AppBskyFeedPost } from '@atproto/api'; 3 3 import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url'; 4 4 import { loadConfig } from '../../lib/config/site'; 5 5 6 6 interface Props { 7 - post: BlueskyPost; 8 - showAuthor?: boolean; 7 + post: AppBskyFeedPost.Record; 9 8 showTimestamp?: boolean; 10 9 } 11 10 12 - const { post, showAuthor = false, showTimestamp = true } = Astro.props; 11 + const { post, showTimestamp = true } = Astro.props; 13 12 14 13 // Validate post data 15 14 if (!post || !post.text) { ··· 57 56 --- 58 57 59 58 <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4"> 60 - {showAuthor && post.author && ( 61 - <div class="flex items-center mb-3"> 62 - <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium"> 63 - {post.author.displayName?.[0] || 'U'} 64 - </div> 65 - <div class="ml-3"> 66 - <div class="text-sm font-medium text-gray-900 dark:text-white"> 67 - {post.author.displayName || 'Unknown'} 68 - </div> 69 - <div class="text-xs text-gray-500 dark:text-gray-400"> 70 - @{post.author.handle || 'unknown'} 71 - </div> 72 - </div> 73 - </div> 74 - )} 59 + 75 60 76 61 <div class="text-gray-900 dark:text-white mb-3"> 77 62 {post.text} ··· 80 65 {post.embed && ( 81 66 <div class="mb-3"> 82 67 {/* Handle image embeds */} 83 - {post.embed.$type === 'app.bsky.embed.images' && post.embed.images && ( 68 + {post.embed.$type === 'app.bsky.embed.images' && 'images' in post.embed && post.embed.images && ( 84 69 renderImages(post.embed.images) 85 70 )} 86 71 87 72 {/* Handle external link embeds */} 88 - {post.embed.$type === 'app.bsky.embed.external' && post.embed.external && ( 73 + {post.embed.$type === 'app.bsky.embed.external' && 'external' in post.embed && post.embed.external && ( 89 74 <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3"> 90 75 <div class="text-sm text-gray-600 dark:text-gray-400 mb-1"> 91 76 {post.embed.external.uri} ··· 102 87 )} 103 88 104 89 {/* Handle record embeds (quotes/reposts) */} 105 - {post.embed.$type === 'app.bsky.embed.record' && post.embed.record && ( 90 + {post.embed.$type === 'app.bsky.embed.record' && 'record' in post.embed && post.embed.record && ( 106 91 <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-700"> 107 92 <div class="text-sm text-gray-600 dark:text-gray-400"> 108 93 Quoted post
+49
src/components/content/ContentDisplay.astro
··· 1 + --- 2 + import type { AtprotoRecord } from '../../lib/atproto/atproto-browser'; 3 + import type { GeneratedLexiconUnion } from '../../lib/generated/lexicon-types'; 4 + import { getComponentInfo, autoAssignComponent } from '../../lib/components/registry'; 5 + import { loadConfig } from '../../lib/config/site'; 6 + 7 + interface Props { 8 + record: AtprotoRecord; 9 + showAuthor?: boolean; 10 + showTimestamp?: boolean; 11 + } 12 + 13 + const { record, showAuthor = true, showTimestamp = true } = Astro.props; 14 + const config = loadConfig(); 15 + 16 + // Extract $type from the record value 17 + const recordType = record.value?.$type || 'unknown'; 18 + 19 + // Try to get component info from registry 20 + const componentInfo = getComponentInfo(recordType as any) || autoAssignComponent(recordType); 21 + 22 + // Dynamic component import 23 + let Component: any = null; 24 + try { 25 + // Try to import the component dynamically 26 + const componentPath = `../../components/content/${componentInfo.component}.astro`; 27 + Component = await import(componentPath); 28 + } catch (error) { 29 + console.warn(`Component ${componentInfo.component} not found for type ${recordType}`); 30 + } 31 + 32 + --- 33 + 34 + {Component && <Component.default 35 + record={record.value} 36 + showAuthor={showAuthor} 37 + showTimestamp={showTimestamp} 38 + {...componentInfo.props} 39 + />} 40 + 41 + {process.env.NODE_ENV === 'development' && ( 42 + <div class="mt-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-sm"> 43 + <h3 class="font-semibold mb-2">Debug Info:</h3> 44 + <p><strong>Type:</strong> {recordType}</p> 45 + <p><strong>Component:</strong> {componentInfo.component}</p> 46 + <p><strong>Record:</strong></p> 47 + <pre class="text-xs overflow-auto">{JSON.stringify(record, null, 2)}</pre> 48 + </div> 49 + )}
+112 -9
src/components/content/ContentFeed.astro
··· 1 1 --- 2 2 import { AtprotoBrowser } from '../../lib/atproto/atproto-browser'; 3 3 import { loadConfig } from '../../lib/config/site'; 4 - import type { AtprotoRecord } from '../../lib/types/atproto'; 5 - import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob'; 4 + import type { AtprotoRecord } from '../../lib/atproto/atproto-browser'; 5 + import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url'; 6 + 6 7 7 8 interface Props { 8 - handle: string; 9 9 collection?: string; 10 10 limit?: number; 11 11 feedUri?: string; 12 - showAuthor?: boolean; 13 12 showTimestamp?: boolean; 13 + live?: boolean; 14 14 } 15 15 16 16 const { 17 - handle, 18 17 collection = 'app.bsky.feed.post', 19 18 limit = 10, 20 19 feedUri, 21 - showAuthor = true, 22 - showTimestamp = true 20 + showTimestamp = true, 21 + live = false, 23 22 } = Astro.props; 24 23 25 24 const config = loadConfig(); 25 + const handle = config.atproto.handle; 26 26 const browser = new AtprotoBrowser(); 27 27 28 28 // Helper function to get image URL from blob reference ··· 61 61 62 62 <div class="space-y-6"> 63 63 {records.length > 0 ? ( 64 - <div class="space-y-4"> 64 + <div id="feed-container" class="space-y-4" data-show-timestamp={String(showTimestamp)} data-initial-limit={String(limit)} data-did={config.atproto.did}> 65 65 {records.map((record) => { 66 66 if (record.value?.$type !== 'app.bsky.feed.post') return null; 67 67 ··· 136 136 <p class="text-sm mt-2">Debug: Handle = {handle}, Records fetched = {records.length}</p> 137 137 </div> 138 138 )} 139 - </div> 139 + </div> 140 + 141 + {live && ( 142 + <script> 143 + // @ts-nocheck 144 + const container = document.getElementById('feed-container'); 145 + if (container) { 146 + const SHOW_TIMESTAMP = container.getAttribute('data-show-timestamp') === 'true'; 147 + const INITIAL_LIMIT = Number(container.getAttribute('data-initial-limit') || '10'); 148 + const maxPrepend = 20; 149 + const DID = container.getAttribute('data-did') || ''; 150 + 151 + function extractCid(ref) { 152 + if (typeof ref === 'string') return ref; 153 + if (ref && typeof ref === 'object') { 154 + if (typeof ref.$link === 'string') return ref.$link; 155 + if (typeof ref.toString === 'function') return ref.toString(); 156 + } 157 + return null; 158 + } 159 + 160 + function buildImagesEl(post, did) { 161 + if (post?.embed?.$type !== 'app.bsky.embed.images' || !post.embed.images) return null; 162 + const grid = document.createElement('div'); 163 + const count = post.embed.images.length; 164 + const cols = count === 1 ? 'grid-cols-1' : count === 2 ? 'grid-cols-2' : 'grid-cols-3'; 165 + grid.className = `grid gap-2 ${cols}`; 166 + for (const img of post.embed.images) { 167 + const cid = extractCid(img?.image?.ref); 168 + if (!cid) continue; 169 + const url = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 170 + const wrapper = document.createElement('div'); 171 + wrapper.className = 'relative'; 172 + const image = document.createElement('img'); 173 + image.src = url; 174 + image.alt = img?.alt || 'Post image'; 175 + image.className = 'rounded-lg w-full h-auto object-cover'; 176 + const arW = (img?.aspectRatio && img.aspectRatio.width) || 1; 177 + const arH = (img?.aspectRatio && img.aspectRatio.height) || 1; 178 + // @ts-ignore 179 + image.style.aspectRatio = `${arW} / ${arH}`; 180 + wrapper.appendChild(image); 181 + grid.appendChild(wrapper); 182 + } 183 + return grid; 184 + } 185 + 186 + function buildPostEl(post, did) { 187 + const article = document.createElement('article'); 188 + article.className = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4'; 189 + 190 + const textDiv = document.createElement('div'); 191 + textDiv.className = 'text-gray-900 dark:text-white mb-3'; 192 + textDiv.textContent = post?.text ? String(post.text) : ''; 193 + article.appendChild(textDiv); 194 + 195 + const imagesEl = buildImagesEl(post, did); 196 + if (imagesEl) { 197 + const imagesWrap = document.createElement('div'); 198 + imagesWrap.className = 'mb-3'; 199 + imagesWrap.appendChild(imagesEl); 200 + article.appendChild(imagesWrap); 201 + } 202 + 203 + if (SHOW_TIMESTAMP && post?.createdAt) { 204 + const timeDiv = document.createElement('div'); 205 + timeDiv.className = 'text-xs text-gray-500 dark:text-gray-400'; 206 + timeDiv.textContent = new Date(post.createdAt).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' }); 207 + article.appendChild(timeDiv); 208 + } 209 + 210 + return article; 211 + } 212 + 213 + try { 214 + // Use shared jetstream instead of creating a new connection 215 + const { startSharedStream, subscribeToPosts } = await import('../../lib/atproto/jetstream-client'); 216 + 217 + // Start the shared stream 218 + await startSharedStream(); 219 + 220 + // Subscribe to new posts 221 + const unsubscribe = subscribeToPosts((event) => { 222 + if (event.commit.operation === 'create') { 223 + const el = buildPostEl(event.commit.record, event.did); 224 + // @ts-ignore 225 + container.insertBefore(el, container.firstChild); 226 + const posts = container.children; 227 + if (posts.length > maxPrepend + INITIAL_LIMIT) { 228 + if (container.lastElementChild) container.removeChild(container.lastElementChild); 229 + } 230 + } 231 + }); 232 + 233 + // Cleanup on page unload 234 + window.addEventListener('beforeunload', () => { 235 + unsubscribe(); 236 + }); 237 + } catch (e) { 238 + console.error('jetstream start error', e); 239 + } 240 + } 241 + </script> 242 + )}
-68
src/components/content/GrainImageGallery.astro
··· 1 - --- 2 - import type { GrainImageGallery } from '../../lib/types/atproto'; 3 - 4 - interface Props { 5 - gallery: GrainImageGallery; 6 - showDescription?: boolean; 7 - showTimestamp?: boolean; 8 - columns?: number; 9 - } 10 - 11 - const { gallery, showDescription = true, showTimestamp = true, columns = 3 } = Astro.props; 12 - 13 - const formatDate = (dateString: string) => { 14 - return new Date(dateString).toLocaleDateString('en-US', { 15 - year: 'numeric', 16 - month: 'long', 17 - day: 'numeric', 18 - }); 19 - }; 20 - 21 - const gridCols = { 22 - 1: 'grid-cols-1', 23 - 2: 'grid-cols-2', 24 - 3: 'grid-cols-3', 25 - 4: 'grid-cols-4', 26 - 5: 'grid-cols-5', 27 - 6: 'grid-cols-6', 28 - }[columns] || 'grid-cols-3'; 29 - --- 30 - 31 - <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 32 - <header class="mb-4"> 33 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 34 - {gallery.title} 35 - </h2> 36 - 37 - {showDescription && gallery.description && ( 38 - <div class="text-gray-600 dark:text-gray-400 mb-3"> 39 - {gallery.description} 40 - </div> 41 - )} 42 - 43 - {showTimestamp && ( 44 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-4"> 45 - Created on {formatDate(gallery.createdAt)} 46 - </div> 47 - )} 48 - </header> 49 - 50 - {gallery.images && gallery.images.length > 0 && ( 51 - <div class={`grid ${gridCols} gap-4`}> 52 - {gallery.images.map((image) => ( 53 - <div class="relative group"> 54 - <img 55 - src={image.url} 56 - alt={image.alt || 'Gallery image'} 57 - class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105" 58 - /> 59 - {image.alt && ( 60 - <div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200"> 61 - {image.alt} 62 - </div> 63 - )} 64 - </div> 65 - ))} 66 - </div> 67 - )} 68 - </article>
-47
src/components/content/LeafletPublication.astro
··· 1 - --- 2 - import type { LeafletPublication } from '../../lib/types/atproto'; 3 - 4 - interface Props { 5 - publication: LeafletPublication; 6 - showCategory?: boolean; 7 - showTimestamp?: boolean; 8 - } 9 - 10 - const { publication, showCategory = true, showTimestamp = true } = Astro.props; 11 - 12 - const formatDate = (dateString: string) => { 13 - return new Date(dateString).toLocaleDateString('en-US', { 14 - year: 'numeric', 15 - month: 'long', 16 - day: 'numeric', 17 - }); 18 - }; 19 - --- 20 - 21 - <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 22 - <header class="mb-4"> 23 - <div class="flex items-center justify-between mb-2"> 24 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white"> 25 - {publication.title} 26 - </h2> 27 - 28 - {showCategory && publication.category && ( 29 - <span class="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-sm rounded-full"> 30 - {publication.category} 31 - </span> 32 - )} 33 - </div> 34 - 35 - {showTimestamp && ( 36 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-3"> 37 - Published on {formatDate(publication.publishedAt)} 38 - </div> 39 - )} 40 - </header> 41 - 42 - <div class="prose prose-gray dark:prose-invert max-w-none"> 43 - <div class="text-gray-700 dark:text-gray-300 leading-relaxed"> 44 - {publication.content} 45 - </div> 46 - </div> 47 - </article>
+115
src/components/content/StatusUpdate.astro
··· 1 + --- 2 + import type { AStatusUpdateRecord } from '../../lib/generated/a-status-update'; 3 + import { AtprotoBrowser } from '../../lib/atproto/atproto-browser'; 4 + import { loadConfig } from '../../lib/config/site'; 5 + 6 + const config = loadConfig(); 7 + const client = new AtprotoBrowser(); 8 + 9 + // Fetch the latest status update 10 + let latestStatus: AStatusUpdateRecord | null = null; 11 + try { 12 + const records = await client.getAllCollectionRecords(config.atproto.handle, 'a.status.update', 1); 13 + 14 + if (records.length > 0) { 15 + latestStatus = records[0].value as AStatusUpdateRecord; 16 + } 17 + } catch (error) { 18 + console.error('Failed to fetch status update:', error); 19 + } 20 + --- 21 + 22 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4" id="status-update-container"> 23 + {latestStatus ? ( 24 + <div class="space-y-2"> 25 + <p class="text-lg font-medium text-gray-900 dark:text-white leading-relaxed" id="status-text"> 26 + {latestStatus.text} 27 + </p> 28 + <time class="text-sm text-gray-500 dark:text-gray-400 block" id="status-time" datetime={latestStatus.createdAt}> 29 + {new Date(latestStatus.createdAt).toLocaleDateString('en-US', { 30 + year: 'numeric', 31 + month: 'short', 32 + day: 'numeric', 33 + hour: '2-digit', 34 + minute: '2-digit' 35 + })} 36 + </time> 37 + </div> 38 + ) : ( 39 + <div class="text-center py-4" id="status-placeholder"> 40 + <p class="text-gray-500 italic">No status updates available</p> 41 + </div> 42 + )} 43 + </div> 44 + 45 + <script> 46 + import { startSharedStream, subscribeToStatusUpdates } from '../../lib/atproto/jetstream-client'; 47 + 48 + // Start the shared stream 49 + startSharedStream(); 50 + 51 + // Subscribe to status updates 52 + const unsubscribe = subscribeToStatusUpdates((event) => { 53 + if (event.commit.operation === 'create') { 54 + updateStatusDisplay(event.commit.record); 55 + } 56 + }); 57 + 58 + function updateStatusDisplay(statusData: any) { 59 + const container = document.getElementById('status-update-container'); 60 + const textEl = document.getElementById('status-text'); 61 + const timeEl = document.getElementById('status-time'); 62 + const placeholderEl = document.getElementById('status-placeholder'); 63 + 64 + if (!container) return; 65 + 66 + // Remove placeholder if it exists 67 + if (placeholderEl) { 68 + placeholderEl.remove(); 69 + } 70 + 71 + // Update or create text element 72 + if (textEl) { 73 + textEl.textContent = statusData.text; 74 + } else { 75 + const newTextEl = document.createElement('p'); 76 + newTextEl.className = 'text-lg font-medium text-gray-900 dark:text-white leading-relaxed'; 77 + newTextEl.id = 'status-text'; 78 + newTextEl.textContent = statusData.text; 79 + container.appendChild(newTextEl); 80 + } 81 + 82 + // Update or create time element 83 + const formattedTime = new Date(statusData.createdAt).toLocaleDateString('en-US', { 84 + year: 'numeric', 85 + month: 'short', 86 + day: 'numeric', 87 + hour: '2-digit', 88 + minute: '2-digit' 89 + }); 90 + 91 + if (timeEl) { 92 + timeEl.textContent = formattedTime; 93 + timeEl.setAttribute('datetime', statusData.createdAt); 94 + } else { 95 + const newTimeEl = document.createElement('time'); 96 + newTimeEl.className = 'text-sm text-gray-500 dark:text-gray-400 block'; 97 + newTimeEl.id = 'status-time'; 98 + newTimeEl.setAttribute('datetime', statusData.createdAt); 99 + newTimeEl.textContent = formattedTime; 100 + container.appendChild(newTimeEl); 101 + } 102 + 103 + // Add a subtle animation to indicate the update 104 + container.style.transition = 'all 0.3s ease'; 105 + container.style.transform = 'scale(1.02)'; 106 + setTimeout(() => { 107 + container.style.transform = 'scale(1)'; 108 + }, 300); 109 + } 110 + 111 + // Cleanup on page unload 112 + window.addEventListener('beforeunload', () => { 113 + unsubscribe(); 114 + }); 115 + </script>
+11 -17
src/components/content/WhitewindBlogPost.astro
··· 1 1 --- 2 - import type { WhitewindBlogPost } from '../../lib/types/atproto'; 2 + import type { ComWhtwndBlogEntryRecord } from '../../lib/generated/com-whtwnd-blog-entry'; 3 + import { marked } from 'marked'; 3 4 4 5 interface Props { 5 - post: WhitewindBlogPost; 6 + record: ComWhtwndBlogEntryRecord; 6 7 showTags?: boolean; 7 8 showTimestamp?: boolean; 8 9 } 9 10 10 - const { post, showTags = true, showTimestamp = true } = Astro.props; 11 + const { record, showTags = true, showTimestamp = true } = Astro.props; 11 12 12 13 const formatDate = (dateString: string) => { 13 14 return new Date(dateString).toLocaleDateString('en-US', { ··· 16 17 day: 'numeric', 17 18 }); 18 19 }; 20 + 21 + const published = record.createdAt; 22 + const isValidDate = published ? !isNaN(new Date(published).getTime()) : false; 19 23 --- 20 24 21 25 <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 22 26 <header class="mb-4"> 23 27 <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 24 - {post.title} 28 + {record.title} 25 29 </h2> 26 30 27 - {showTimestamp && ( 31 + {showTimestamp && isValidDate && ( 28 32 <div class="text-sm text-gray-500 dark:text-gray-400 mb-3"> 29 - Published on {formatDate(post.publishedAt)} 30 - </div> 31 - )} 32 - 33 - {showTags && post.tags && post.tags.length > 0 && ( 34 - <div class="flex flex-wrap gap-2 mb-4"> 35 - {post.tags.map((tag) => ( 36 - <span class="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded-full"> 37 - #{tag} 38 - </span> 39 - ))} 33 + Published on {formatDate(published!)} 40 34 </div> 41 35 )} 42 36 </header> 43 37 44 38 <div class="prose prose-gray dark:prose-invert max-w-none"> 45 39 <div class="text-gray-700 dark:text-gray-300 leading-relaxed"> 46 - {post.content} 40 + <Fragment set:html={await marked(record.content || '')} /> 47 41 </div> 48 42 </div> 49 43 </article>
+13
src/content.config.ts
··· 1 + import { defineCollection, z } from "astro:content"; 2 + import { leafletStaticLoader } from "@nulfrost/leaflet-loader-astro"; 3 + import { loadConfig } from "./lib/config/site"; 4 + 5 + const config = loadConfig(); 6 + 7 + const documents = defineCollection({ 8 + loader: leafletStaticLoader({ 9 + repo: config.atproto.did || config.atproto.handle || "did:plc:example" 10 + }), 11 + }); 12 + 13 + export const collections = { documents };
+4 -10
src/layouts/Layout.astro
··· 35 35 <a href="/galleries" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 36 36 Galleries 37 37 </a> 38 - <a href="/debug" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 39 - Debug 40 - </a> 41 - <a href="/test-api" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 42 - Test API 43 - </a> 44 - <a href="/comprehensive-test" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 45 - Comprehensive Test 38 + <a href="/blog" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 39 + Blog 46 40 </a> 47 - <a href="/collection-config" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 48 - Collections 41 + <a href="/now" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 42 + Now 49 43 </a> 50 44 </div> 51 45 </div>
+26
src/lexicons/a.status.update.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "a.status.update", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A simple status update record", 8 + "key": "self", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "description": "The status update text" 16 + }, 17 + "createdAt": { 18 + "type": "string", 19 + "format": "datetime", 20 + "description": "When the status was created" 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+66
src/lexicons/com.whtwnd.blog.entry.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.whtwnd.blog.entry", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a post.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "content" 13 + ], 14 + "properties": { 15 + "content": { 16 + "type": "string", 17 + "maxLength": 100000 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + }, 23 + "title": { 24 + "type": "string", 25 + "maxLength": 1000 26 + }, 27 + "subtitle": { 28 + "type": "string", 29 + "maxLength": 1000 30 + }, 31 + "ogp": { 32 + "type": "ref", 33 + "ref": "com.whtwnd.blog.defs#ogp" 34 + }, 35 + "theme": { 36 + "type": "string", 37 + "enum": [ 38 + "github-light" 39 + ] 40 + }, 41 + "blobs": { 42 + "type": "array", 43 + "items": { 44 + "type": "ref", 45 + "ref": "com.whtwnd.blog.defs#blobMetadata" 46 + } 47 + }, 48 + "isDraft": { 49 + "type": "boolean", 50 + "description": "(DEPRECATED) Marks this entry as draft to tell AppViews not to show it to anyone except for the author" 51 + }, 52 + "visibility": { 53 + "type": "string", 54 + "enum": [ 55 + "public", 56 + "url", 57 + "author" 58 + ], 59 + "default": "public", 60 + "description": "Tells the visibility of the article to AppView." 61 + } 62 + } 63 + } 64 + } 65 + } 66 + }
+32
src/lexicons/social.grain.gallery.item.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery.item", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["createdAt", "gallery", "item"], 11 + "properties": { 12 + "createdAt": { 13 + "type": "string", 14 + "format": "datetime" 15 + }, 16 + "gallery": { 17 + "type": "string", 18 + "format": "at-uri" 19 + }, 20 + "item": { 21 + "type": "string", 22 + "format": "at-uri" 23 + }, 24 + "position": { 25 + "type": "integer", 26 + "default": 0 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+30
src/lexicons/social.grain.gallery.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["title", "createdAt"], 11 + "properties": { 12 + "title": { "type": "string", "maxLength": 100 }, 13 + "description": { "type": "string", "maxLength": 1000 }, 14 + "facets": { 15 + "type": "array", 16 + "description": "Annotations of description text (mentions, URLs, hashtags, etc)", 17 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 18 + }, 19 + "labels": { 20 + "type": "union", 21 + "description": "Self-label values for this post. Effectively content warnings.", 22 + "refs": ["com.atproto.label.defs#selfLabels"] 23 + }, 24 + "updatedAt": { "type": "string", "format": "datetime" }, 25 + "createdAt": { "type": "string", "format": "datetime" } 26 + } 27 + } 28 + } 29 + } 30 + }
+32
src/lexicons/social.grain.photo.exif.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo.exif", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "photo", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "photo": { "type": "string", "format": "at-uri" }, 17 + "createdAt": { "type": "string", "format": "datetime" }, 18 + "dateTimeOriginal": { "type": "string", "format": "datetime" }, 19 + "exposureTime": { "type": "integer" }, 20 + "fNumber": { "type": "integer" }, 21 + "flash": { "type": "string" }, 22 + "focalLengthIn35mmFormat": { "type": "integer" }, 23 + "iSO": { "type": "integer" }, 24 + "lensMake": { "type": "string" }, 25 + "lensModel": { "type": "string" }, 26 + "make": { "type": "string" }, 27 + "model": { "type": "string" } 28 + } 29 + } 30 + } 31 + } 32 + }
+30
src/lexicons/social.grain.photo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["photo", "aspectRatio", "createdAt"], 11 + "properties": { 12 + "photo": { 13 + "type": "blob", 14 + "accept": ["image/*"], 15 + "maxSize": 1000000 16 + }, 17 + "alt": { 18 + "type": "string", 19 + "description": "Alt text description of the image, for accessibility." 20 + }, 21 + "aspectRatio": { 22 + "type": "ref", 23 + "ref": "social.grain.defs#aspectRatio" 24 + }, 25 + "createdAt": { "type": "string", "format": "datetime" } 26 + } 27 + } 28 + } 29 + } 30 + }
+13 -4
src/lib/atproto/atproto-browser.ts
··· 184 184 // Get a specific record 185 185 async getRecord(uri: string): Promise<AtprotoRecord | null> { 186 186 try { 187 + // Parse at://did:.../collection/rkey 188 + if (!uri.startsWith('at://')) throw new Error('Invalid at:// URI'); 189 + const parts = uri.replace('at://', '').split('/'); 190 + const repo = parts[0]; 191 + const collection = parts[1]; 192 + const rkey = parts[2]; 193 + 187 194 const response = await this.agent.api.com.atproto.repo.getRecord({ 188 - uri: uri, 195 + repo, 196 + collection, 197 + rkey, 189 198 }); 190 199 191 - const record = response.data; 200 + const record = response.data as any; 192 201 return { 193 - uri: record.uri, 202 + uri: `at://${repo}/${collection}/${rkey}`, 194 203 cid: record.cid, 195 204 value: record.value, 196 205 indexedAt: record.indexedAt, 197 - collection: record.uri.split('/')[2] || 'unknown', 206 + collection: collection || 'unknown', 198 207 $type: (record.value?.$type as string) || 'unknown', 199 208 }; 200 209 } catch (error) {
+29
src/lib/atproto/blob-url.ts
··· 1 + import { loadConfig } from '../config/site' 2 + 3 + export type BlobVariant = 'full' | 'avatar' | 'feed' 4 + 5 + export function blobCdnUrl(did: string, cid: string, _variant: BlobVariant = 'full'): string { 6 + const base = 'https://bsky.social/xrpc/com.atproto.sync.getBlob' 7 + const params = new URLSearchParams({ did, cid }) 8 + return `${base}?${params.toString()}` 9 + } 10 + 11 + export function extractCidFromBlobRef(ref: unknown): string | null { 12 + if (typeof ref === 'string') return ref 13 + if (ref && typeof ref === 'object') { 14 + const anyRef = ref as any 15 + if (typeof anyRef.$link === 'string') return anyRef.$link 16 + if (typeof anyRef.toString === 'function') { 17 + const s = anyRef.toString() 18 + if (s && typeof s === 'string') return s 19 + } 20 + } 21 + return null 22 + } 23 + 24 + export function didFromConfig(): string { 25 + const cfg = loadConfig() 26 + return cfg.atproto.did || '' 27 + } 28 + 29 +
+187 -168
src/lib/atproto/jetstream-client.ts
··· 1 - // Jetstream-based repository streaming with DID filtering (based on atptools) 1 + // Complete Jetstream implementation using documented @atcute/jetstream approach 2 + import { JetstreamSubscription, type CommitEvent } from '@atcute/jetstream'; 2 3 import { loadConfig } from '../config/site'; 3 4 4 - export interface JetstreamRecord { 5 - uri: string; 6 - cid: string; 7 - value: any; 8 - indexedAt: string; 9 - collection: string; 10 - $type: string; 11 - service: string; 12 - did: string; 13 - time_us: number; 14 - operation: 'create' | 'update' | 'delete'; 15 - } 16 - 17 5 export interface JetstreamConfig { 18 6 handle: string; 19 7 did?: string; ··· 24 12 } 25 13 26 14 export class JetstreamClient { 27 - private ws: WebSocket | null = null; 15 + private subscription: JetstreamSubscription | null = null; 28 16 private config: JetstreamConfig; 29 - private targetDid: string | null = null; 30 17 private isStreaming = false; 31 18 private listeners: { 32 - onRecord?: (record: JetstreamRecord) => void; 19 + onRecord?: (event: CommitEvent) => void; 33 20 onError?: (error: Error) => void; 34 21 onConnect?: () => void; 35 22 onDisconnect?: () => void; ··· 40 27 this.config = { 41 28 handle: config?.handle || siteConfig.atproto.handle, 42 29 did: config?.did || siteConfig.atproto.did, 43 - endpoint: config?.endpoint || 'wss://jetstream1.us-east.bsky.network/subscribe', 30 + endpoint: config?.endpoint || 'wss://jetstream2.us-east.bsky.network', 44 31 wantedCollections: config?.wantedCollections || [], 45 32 wantedDids: config?.wantedDids || [], 46 33 cursor: config?.cursor, 47 34 }; 48 - this.targetDid = this.config.did || null; 49 35 50 - console.log('๐Ÿ”ง JetstreamClient initialized with handle:', this.config.handle); 51 - console.log('๐ŸŽฏ Target DID for filtering:', this.targetDid); 52 - console.log('๐ŸŒ Endpoint:', this.config.endpoint); 36 + console.log('๐Ÿ”ง JetstreamClient initialized'); 53 37 } 54 38 55 - // Start streaming all repository activity 56 39 async startStreaming(): Promise<void> { 57 40 if (this.isStreaming) { 58 - console.log('โš ๏ธ Already streaming repository'); 41 + console.log('โš ๏ธ Already streaming'); 59 42 return; 60 43 } 61 44 62 - console.log('๐Ÿš€ Starting jetstream repository streaming...'); 45 + console.log('๐Ÿš€ Starting jetstream streaming...'); 63 46 this.isStreaming = true; 64 47 65 48 try { 66 - // Resolve handle to DID if needed 67 - if (!this.targetDid) { 68 - this.targetDid = await this.resolveHandle(this.config.handle); 69 - if (!this.targetDid) { 70 - throw new Error(`Could not resolve handle: ${this.config.handle}`); 71 - } 72 - console.log('โœ… Resolved DID:', this.targetDid); 49 + // Add our DID to wanted DIDs if specified 50 + const wantedDids = [...(this.config.wantedDids || [])]; 51 + if (this.config.did && !wantedDids.includes(this.config.did)) { 52 + wantedDids.push(this.config.did); 73 53 } 74 54 75 - // Add target DID to wanted DIDs 76 - if (this.targetDid && !this.config.wantedDids!.includes(this.targetDid)) { 77 - this.config.wantedDids!.push(this.targetDid); 78 - } 55 + this.subscription = new JetstreamSubscription({ 56 + url: this.config.endpoint!, 57 + wantedCollections: this.config.wantedCollections, 58 + wantedDids: wantedDids as any, 59 + cursor: this.config.cursor, 60 + onConnectionOpen: () => { 61 + console.log('โœ… Connected to jetstream'); 62 + this.listeners.onConnect?.(); 63 + }, 64 + onConnectionClose: () => { 65 + console.log('๐Ÿ”Œ Disconnected from jetstream'); 66 + this.isStreaming = false; 67 + this.listeners.onDisconnect?.(); 68 + }, 69 + onConnectionError: (error) => { 70 + console.error('โŒ Jetstream connection error:', error); 71 + this.listeners.onError?.(new Error('Connection error')); 72 + }, 73 + }); 79 74 80 - // Start WebSocket connection 81 - this.connect(); 75 + // Process events using async iteration as documented 76 + this.processEvents(); 82 77 83 78 } catch (error) { 84 79 this.isStreaming = false; ··· 86 81 } 87 82 } 88 83 89 - // Stop streaming 90 - stopStreaming(): void { 91 - if (this.ws) { 92 - this.ws.close(); 93 - this.ws = null; 94 - } 95 - this.isStreaming = false; 96 - console.log('๐Ÿ›‘ Stopped jetstream streaming'); 97 - this.listeners.onDisconnect?.(); 98 - } 84 + private async processEvents(): Promise<void> { 85 + if (!this.subscription) return; 99 86 100 - // Connect to jetstream WebSocket 101 - private connect(): void { 102 87 try { 103 - const url = new URL(this.config.endpoint!); 104 - 105 - // Add query parameters for filtering (using atptools' parameter names) 106 - this.config.wantedCollections!.forEach((collection) => { 107 - url.searchParams.append('wantedCollections', collection); 108 - }); 109 - this.config.wantedDids!.forEach((did) => { 110 - url.searchParams.append('wantedDids', did); 111 - }); 112 - if (this.config.cursor) { 113 - url.searchParams.set('cursor', this.config.cursor.toString()); 88 + // Use the documented async iteration approach 89 + for await (const event of this.subscription) { 90 + if (event.kind === 'commit') { 91 + console.log('๐Ÿ“ New commit:', { 92 + collection: event.commit.collection, 93 + operation: event.commit.operation, 94 + did: event.did, 95 + }); 96 + 97 + this.listeners.onRecord?.(event); 98 + } 114 99 } 115 - 116 - console.log('๐Ÿ”Œ Connecting to jetstream:', url.toString()); 117 - 118 - this.ws = new WebSocket(url.toString()); 119 - 120 - this.ws.onopen = () => { 121 - console.log('โœ… Connected to jetstream'); 122 - this.listeners.onConnect?.(); 123 - }; 124 - 125 - this.ws.onmessage = (event) => { 126 - try { 127 - const data = JSON.parse(event.data); 128 - this.handleMessage(data); 129 - } catch (error) { 130 - console.error('Error parsing jetstream message:', error); 131 - } 132 - }; 133 - 134 - this.ws.onerror = (error) => { 135 - console.error('โŒ Jetstream WebSocket error:', error); 136 - this.listeners.onError?.(new Error('WebSocket error')); 137 - }; 138 - 139 - this.ws.onclose = () => { 140 - console.log('๐Ÿ”Œ Disconnected from jetstream'); 141 - this.isStreaming = false; 142 - this.listeners.onDisconnect?.(); 143 - }; 144 - 145 100 } catch (error) { 146 - console.error('Error connecting to jetstream:', error); 101 + console.error('Error processing jetstream events:', error); 147 102 this.listeners.onError?.(error as Error); 103 + } finally { 104 + this.isStreaming = false; 105 + this.listeners.onDisconnect?.(); 148 106 } 149 107 } 150 108 151 - // Handle incoming jetstream messages 152 - private handleMessage(data: any): void { 153 - try { 154 - // Handle different message types based on atptools' format 155 - if (data.kind === 'commit') { 156 - this.handleCommit(data); 157 - } else if (data.kind === 'account') { 158 - console.log('Account event:', data); 159 - } else if (data.kind === 'identity') { 160 - console.log('Identity event:', data); 161 - } else { 162 - console.log('Unknown message type:', data); 163 - } 164 - } catch (error) { 165 - console.error('Error handling jetstream message:', error); 166 - } 167 - } 168 - 169 - // Handle commit events (record changes) 170 - private handleCommit(data: any): void { 171 - try { 172 - const commit = data.commit; 173 - const event = data; 174 - 175 - // Filter by DID if specified 176 - if (this.targetDid && event.did !== this.targetDid) { 177 - return; 178 - } 179 - 180 - const jetstreamRecord: JetstreamRecord = { 181 - uri: `at://${event.did}/${commit.collection}/${commit.rkey}`, 182 - cid: commit.cid || '', 183 - value: commit.record || {}, 184 - indexedAt: new Date(event.time_us / 1000).toISOString(), 185 - collection: commit.collection, 186 - $type: (commit.record?.$type as string) || 'unknown', 187 - service: this.inferService((commit.record?.$type as string) || '', commit.collection), 188 - did: event.did, 189 - time_us: event.time_us, 190 - operation: commit.operation, 191 - }; 192 - 193 - console.log('๐Ÿ“ New record from jetstream:', { 194 - collection: jetstreamRecord.collection, 195 - $type: jetstreamRecord.$type, 196 - operation: jetstreamRecord.operation, 197 - uri: jetstreamRecord.uri, 198 - service: jetstreamRecord.service 199 - }); 200 - 201 - this.listeners.onRecord?.(jetstreamRecord); 202 - } catch (error) { 203 - console.error('Error handling commit:', error); 204 - } 205 - } 206 - 207 - // Infer service from record type and collection 208 - private inferService($type: string, collection: string): string { 209 - if (collection.startsWith('grain.social')) return 'grain.social'; 210 - if (collection.startsWith('app.bsky')) return 'bsky.app'; 211 - if ($type.includes('grain')) return 'grain.social'; 212 - return 'unknown'; 213 - } 214 - 215 - // Resolve handle to DID 216 - private async resolveHandle(handle: string): Promise<string | null> { 217 - try { 218 - // For now, use the configured DID 219 - // In a real implementation, you'd call the ATProto API 220 - return this.config.did || null; 221 - } catch (error) { 222 - console.error('Error resolving handle:', error); 223 - return null; 224 - } 109 + stopStreaming(): void { 110 + this.subscription = null; 111 + this.isStreaming = false; 112 + console.log('๐Ÿ›‘ Stopped jetstream streaming'); 113 + this.listeners.onDisconnect?.(); 225 114 } 226 115 227 116 // Event listeners 228 - onRecord(callback: (record: JetstreamRecord) => void): void { 117 + onRecord(callback: (event: CommitEvent) => void): void { 229 118 this.listeners.onRecord = callback; 230 119 } 231 120 ··· 241 130 this.listeners.onDisconnect = callback; 242 131 } 243 132 244 - // Get streaming status 245 133 getStatus(): 'streaming' | 'stopped' { 246 134 return this.isStreaming ? 'streaming' : 'stopped'; 247 135 } 136 + } 137 + 138 + // Shared Jetstream functionality 139 + let sharedJetstream: JetstreamClient | null = null; 140 + let connectionCount = 0; 141 + const listeners: Map<string, Set<(event: CommitEvent) => void>> = new Map(); 142 + 143 + export function getSharedJetstream(): JetstreamClient { 144 + if (!sharedJetstream) { 145 + // Create a shared client with common collections 146 + sharedJetstream = new JetstreamClient({ 147 + wantedCollections: [ 148 + 'app.bsky.feed.post', 149 + 'a.status.update', 150 + 'social.grain.gallery', 151 + 'social.grain.gallery.item', 152 + 'social.grain.photo', 153 + 'com.whtwnd.blog.entry' 154 + ] 155 + }); 156 + 157 + // Set up the main record handler that distributes to filtered listeners 158 + sharedJetstream.onRecord((event) => { 159 + // Distribute to all listeners that match the filter 160 + listeners.forEach((listenerSet, filterKey) => { 161 + if (matchesFilter(event, filterKey)) { 162 + listenerSet.forEach(callback => callback(event)); 163 + } 164 + }); 165 + }); 166 + } 167 + return sharedJetstream; 168 + } 169 + 170 + // Start the shared stream (call once when first component needs it) 171 + export async function startSharedStream(): Promise<void> { 172 + const jetstream = getSharedJetstream(); 173 + if (connectionCount === 0) { 174 + await jetstream.startStreaming(); 175 + } 176 + connectionCount++; 177 + } 178 + 179 + // Stop the shared stream (call when last component is done) 180 + export function stopSharedStream(): void { 181 + connectionCount--; 182 + if (connectionCount <= 0 && sharedJetstream) { 183 + sharedJetstream.stopStreaming(); 184 + connectionCount = 0; 185 + } 186 + } 187 + 188 + // Subscribe to filtered records 189 + export function subscribeToRecords( 190 + filter: string | ((event: CommitEvent) => boolean), 191 + callback: (event: CommitEvent) => void 192 + ): () => void { 193 + const filterKey = typeof filter === 'string' ? filter : filter.toString(); 194 + 195 + if (!listeners.has(filterKey)) { 196 + listeners.set(filterKey, new Set()); 197 + } 198 + 199 + const listenerSet = listeners.get(filterKey)!; 200 + listenerSet.add(callback); 201 + 202 + // Return unsubscribe function 203 + return () => { 204 + const set = listeners.get(filterKey); 205 + if (set) { 206 + set.delete(callback); 207 + if (set.size === 0) { 208 + listeners.delete(filterKey); 209 + } 210 + } 211 + }; 212 + } 213 + 214 + // Helper to check if a record matches a filter 215 + function matchesFilter(event: CommitEvent, filterKey: string): boolean { 216 + // Handle delete operations (no record property) 217 + if (event.commit.operation === 'delete') { 218 + // For delete operations, only support collection and operation matching 219 + if (filterKey.startsWith('collection:')) { 220 + const expectedCollection = filterKey.substring(11); 221 + return event.commit.collection === expectedCollection; 222 + } 223 + if (filterKey.startsWith('operation:')) { 224 + const expectedOperation = filterKey.substring(10); 225 + return event.commit.operation === expectedOperation; 226 + } 227 + return false; 228 + } 229 + 230 + // For create/update operations, we have record data 231 + const record = event.commit.record; 232 + const $type = record?.$type as string; 233 + 234 + // Support simple $type matching 235 + if (filterKey.startsWith('$type:')) { 236 + const expectedType = filterKey.substring(6); 237 + return $type === expectedType; 238 + } 239 + 240 + // Support collection matching 241 + if (filterKey.startsWith('collection:')) { 242 + const expectedCollection = filterKey.substring(11); 243 + return event.commit.collection === expectedCollection; 244 + } 245 + 246 + // Support operation matching 247 + if (filterKey.startsWith('operation:')) { 248 + const expectedOperation = filterKey.substring(10); 249 + return event.commit.operation === expectedOperation; 250 + } 251 + 252 + // Default to exact match 253 + return $type === filterKey; 254 + } 255 + 256 + // Convenience functions for common filters 257 + export function subscribeToStatusUpdates(callback: (event: CommitEvent) => void): () => void { 258 + return subscribeToRecords('$type:a.status.update', callback); 259 + } 260 + 261 + export function subscribeToPosts(callback: (event: CommitEvent) => void): () => void { 262 + return subscribeToRecords('$type:app.bsky.feed.post', callback); 263 + } 264 + 265 + export function subscribeToGalleryUpdates(callback: (event: CommitEvent) => void): () => void { 266 + return subscribeToRecords('collection:social.grain.gallery', callback); 248 267 }
-113
src/lib/components/discovered-registry.ts
··· 1 - import type { DiscoveredTypes } from '../generated/discovered-types'; 2 - import type { AnyRecordByType } from '../atproto/record-types'; 3 - 4 - export type DiscoveredComponent<T extends string = DiscoveredTypes> = { 5 - $type: T; 6 - component: string; 7 - props: Record<string, unknown>; 8 - } 9 - 10 - export type ComponentRegistry = Record<string, { component: string; props?: Record<string, unknown> }> 11 - 12 - export class DiscoveredComponentRegistry { 13 - private registry: ComponentRegistry = {}; 14 - private discoveredTypes: DiscoveredTypes[] = []; 15 - 16 - constructor() { 17 - this.initializeRegistry(); 18 - } 19 - 20 - // Initialize the registry with discovered types 21 - private initializeRegistry(): void { 22 - // This will be populated with discovered types 23 - // For now, we'll use a basic mapping 24 - this.registry = { 25 - 'app.bsky.feed.post': { 26 - component: 'BlueskyPost', 27 - props: { showAuthor: false, showTimestamp: true } 28 - }, 29 - 'app.bsky.actor.profile': { 30 - component: 'ProfileDisplay', 31 - props: { showHandle: true } 32 - }, 33 - 'social.grain.gallery': { 34 - component: 'GrainGalleryDisplay', 35 - props: { showCollections: true, columns: 3 } 36 - }, 37 - 'grain.social.feed.gallery': { 38 - component: 'GrainGalleryDisplay', 39 - props: { showCollections: true, columns: 3 } 40 - } 41 - }; 42 - } 43 - 44 - // Register a component for a specific $type 45 - registerComponent($type: DiscoveredTypes, component: string, props?: Record<string, unknown>): void { 46 - this.registry[$type] = { 47 - component, 48 - props 49 - }; 50 - } 51 - 52 - // Get component info for a $type 53 - getComponent($type: DiscoveredTypes): { component: string; props?: Record<string, unknown> } | null { 54 - return this.registry[$type] || null; 55 - } 56 - 57 - // Get all registered $types 58 - getRegisteredTypes(): DiscoveredTypes[] { 59 - return Object.keys(this.registry) as DiscoveredTypes[]; 60 - } 61 - 62 - // Check if a $type has a registered component 63 - hasComponent($type: DiscoveredTypes): boolean { 64 - return $type in this.registry; 65 - } 66 - 67 - // Get component mapping for rendering 68 - getComponentMapping(): ComponentRegistry { 69 - return this.registry; 70 - } 71 - 72 - // Update discovered types (called after build-time discovery) 73 - updateDiscoveredTypes(types: DiscoveredTypes[]): void { 74 - this.discoveredTypes = types; 75 - 76 - // Auto-register components for discovered types that don't have explicit mappings 77 - for (const $type of types) { 78 - if (!this.hasComponent($type)) { 79 - // Auto-assign based on service/collection 80 - const component = this.autoAssignComponent($type); 81 - if (component) { 82 - this.registerComponent($type, component); 83 - } 84 - } 85 - } 86 - } 87 - 88 - // Auto-assign component based on $type 89 - private autoAssignComponent($type: DiscoveredTypes): string | null { 90 - if ($type.includes('grain') || $type.includes('gallery')) { 91 - return 'GrainGalleryDisplay'; 92 - } 93 - if ($type.includes('post') || $type.includes('feed')) { 94 - return 'BlueskyPost'; 95 - } 96 - if ($type.includes('profile') || $type.includes('actor')) { 97 - return 'ProfileDisplay'; 98 - } 99 - return 'GenericContentDisplay'; 100 - } 101 - 102 - // Get component info for rendering 103 - getComponentInfo<T extends DiscoveredTypes>($type: T): DiscoveredComponent<T> | null { 104 - const componentInfo = this.getComponent($type); 105 - if (!componentInfo) return null; 106 - 107 - return { 108 - $type, 109 - component: componentInfo.component, 110 - props: componentInfo.props || {} 111 - }; 112 - } 113 - }
-23
src/lib/components/register.ts
··· 1 - import { registerComponent } from './registry'; 2 - import BlueskyPost from '../../components/content/BlueskyPost.astro'; 3 - import WhitewindBlogPost from '../../components/content/WhitewindBlogPost.astro'; 4 - import LeafletPublication from '../../components/content/LeafletPublication.astro'; 5 - import GrainImageGallery from '../../components/content/GrainImageGallery.astro'; 6 - 7 - // Register all content components 8 - export function registerAllComponents() { 9 - // Register Bluesky post component 10 - registerComponent('app.bsky.feed.post', BlueskyPost); 11 - 12 - // Register Whitewind blog post component 13 - registerComponent('app.bsky.actor.profile#whitewindBlogPost', WhitewindBlogPost); 14 - 15 - // Register Leaflet publication component 16 - registerComponent('app.bsky.actor.profile#leafletPublication', LeafletPublication); 17 - 18 - // Register Grain image gallery component 19 - registerComponent('app.bsky.actor.profile#grainImageGallery', GrainImageGallery); 20 - } 21 - 22 - // Auto-register components when this module is imported 23 - registerAllComponents();
+51 -46
src/lib/components/registry.ts
··· 1 - import type { ContentComponent } from '../types/atproto'; 1 + import type { GeneratedLexiconUnion, GeneratedLexiconTypeMap } from '../generated/lexicon-types'; 2 2 3 - // Component registry for type-safe content rendering 4 - class ComponentRegistry { 5 - private components = new Map<string, ContentComponent>(); 3 + // Type-safe component registry 4 + export interface ComponentRegistryEntry<T = any> { 5 + component: string; 6 + props?: T; 7 + } 6 8 7 - // Register a component for a specific content type 8 - register(type: string, component: any, props?: Record<string, any>): void { 9 - this.components.set(type, { 10 - type, 11 - component, 12 - props, 13 - }); 14 - } 9 + export type ComponentRegistry = { 10 + [K in keyof GeneratedLexiconTypeMap]?: ComponentRegistryEntry; 11 + } & { 12 + [key: string]: ComponentRegistryEntry; // Fallback for unknown types 13 + }; 15 14 16 - // Get a component for a specific content type 17 - get(type: string): ContentComponent | undefined { 18 - return this.components.get(type); 19 - } 15 + // Default registry - add your components here 16 + export const registry: ComponentRegistry = { 17 + 'ComWhtwndBlogEntry': { 18 + component: 'WhitewindBlogPost', 19 + props: {} 20 + }, 21 + 'AStatusUpdate': { 22 + component: 'StatusUpdate', 23 + props: {} 24 + }, 25 + // Bluesky posts (not in generated types, but used by components) 26 + 'app.bsky.feed.post': { 27 + component: 'BlueskyPost', 28 + props: {} 29 + }, 20 30 21 - // Check if a component exists for a content type 22 - has(type: string): boolean { 23 - return this.components.has(type); 24 - } 31 + }; 25 32 26 - // Get all registered component types 27 - getRegisteredTypes(): string[] { 28 - return Array.from(this.components.keys()); 29 - } 30 - 31 - // Clear all registered components 32 - clear(): void { 33 - this.components.clear(); 34 - } 33 + // Type-safe component lookup 34 + export function getComponentInfo<T extends keyof GeneratedLexiconTypeMap>( 35 + $type: T 36 + ): ComponentRegistryEntry | null { 37 + return registry[$type] || null; 35 38 } 36 39 37 - // Global component registry instance 38 - export const componentRegistry = new ComponentRegistry(); 39 - 40 - // Type-safe component registration helper 41 - export function registerComponent( 42 - type: string, 43 - component: any, 44 - props?: Record<string, any> 40 + // Helper to register a new component 41 + export function registerComponent<T extends keyof GeneratedLexiconTypeMap>( 42 + $type: T, 43 + component: string, 44 + props?: any 45 45 ): void { 46 - componentRegistry.register(type, component, props); 47 - } 48 - 49 - // Type-safe component retrieval helper 50 - export function getComponent(type: string): ContentComponent | undefined { 51 - return componentRegistry.get(type); 46 + registry[$type] = { component, props }; 52 47 } 53 48 54 - // Check if content type has a registered component 55 - export function hasComponent(type: string): boolean { 56 - return componentRegistry.has(type); 49 + // Auto-assignment for unknown types (fallback) 50 + export function autoAssignComponent($type: string): ComponentRegistryEntry { 51 + // Convert NSID to component name 52 + const parts = $type.split('.'); 53 + const componentName = parts[parts.length - 1] 54 + .split('-') 55 + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) 56 + .join(''); 57 + 58 + return { 59 + component: componentName, 60 + props: {} 61 + }; 57 62 }
+12
src/lib/config/site.ts
··· 33 33 content: { 34 34 defaultFeedLimit: number; 35 35 cacheTTL: number; // in milliseconds 36 + collections: string[]; 37 + maxRecords: number; 36 38 }; 39 + lexiconSources: Record<string, string>; // NSID -> local schema file path 37 40 } 38 41 39 42 // Default configuration with static handle ··· 58 61 content: { 59 62 defaultFeedLimit: 20, 60 63 cacheTTL: 5 * 60 * 1000, // 5 minutes 64 + collections: ['app.bsky.feed.post', 'com.whtwnd.blog.entry'], 65 + maxRecords: 500, 66 + }, 67 + lexiconSources: { 68 + 'com.whtwnd.blog.entry': './src/lexicons/com.whtwnd.blog.entry.json', 69 + // Add more NSIDs -> schema file mappings here 61 70 }, 62 71 }; 63 72 ··· 89 98 content: { 90 99 defaultFeedLimit: parseInt(process.env.CONTENT_DEFAULT_FEED_LIMIT || '20'), 91 100 cacheTTL: parseInt(process.env.CONTENT_CACHE_TTL || '300000'), 101 + collections: defaultConfig.content.collections, // Keep default collections 102 + maxRecords: parseInt(process.env.CONTENT_MAX_RECORDS || '500'), 92 103 }, 104 + lexiconSources: defaultConfig.lexiconSources, // Keep default lexicon sources 93 105 }; 94 106 }
+15
src/lib/generated/a-status-update.ts
··· 1 + // Generated from lexicon schema: a.status.update 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface AStatusUpdateRecord { 5 + text: string; 6 + createdAt: string; 7 + } 8 + 9 + export interface AStatusUpdate { 10 + $type: 'a.status.update'; 11 + value: AStatusUpdateRecord; 12 + } 13 + 14 + // Helper type for discriminated unions 15 + export type AStatusUpdateUnion = AStatusUpdate;
+22
src/lib/generated/com-whtwnd-blog-entry.ts
··· 1 + // Generated from lexicon schema: com.whtwnd.blog.entry 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface ComWhtwndBlogEntryRecord { 5 + content: string; 6 + createdAt?: string; 7 + title?: string; 8 + subtitle?: string; 9 + ogp?: any; 10 + theme?: 'github-light'; 11 + blobs?: any[]; 12 + isDraft?: boolean; 13 + visibility?: 'public' | 'url' | 'author'; 14 + } 15 + 16 + export interface ComWhtwndBlogEntry { 17 + $type: 'com.whtwnd.blog.entry'; 18 + value: ComWhtwndBlogEntryRecord; 19 + } 20 + 21 + // Helper type for discriminated unions 22 + export type ComWhtwndBlogEntryUnion = ComWhtwndBlogEntry;
+2360
src/lib/generated/discovered-types.json
··· 1 + { 2 + "collections": [ 3 + { 4 + "name": "app.bsky.actor.profile", 5 + "description": "Bluesky profile information", 6 + "service": "bsky.app", 7 + "sampleRecords": [ 8 + { 9 + "$type": "app.bsky.actor.profile", 10 + "avatar": { 11 + "$type": "blob", 12 + "ref": { 13 + "$link": "bafkreig6momh2fkdfhhqwkcjsw4vycubptufe6aeolsddtgg6felh4bvoe" 14 + }, 15 + "mimeType": "image/jpeg", 16 + "size": 915425 17 + }, 18 + "banner": { 19 + "$type": "blob", 20 + "ref": { 21 + "$link": "bafkreicyefphti37yc4grac2q47eedkdwj67tnbomr5rwz62ikvrqvvcg4" 22 + }, 23 + "mimeType": "image/jpeg", 24 + "size": 806361 25 + }, 26 + "description": "Experience designer for the decentralized web\n\n๐Ÿ“ Boston ๐Ÿ‡บ๐Ÿ‡ธ\n\nmy work: tynanpurdy.com\nmy writing: blog.tynanpurdy.com\n\nI'm on Germ DM ๐Ÿ”‘ and Signal @tynanpurdy.95\nhttps://ger.mx/A1CM6J3FugcfRh4NCNElrRdMsA9tLViN0QqqcMoMxkdS#did:plc:6ayddqghxhciedbaofoxkcbs", 27 + "displayName": "Tynan Purdy" 28 + } 29 + ], 30 + "generatedTypes": "export interface AppBskyActorProfile {\n $type: 'app.bsky.actor.profile';\n avatar?: Record<string, any>;\n banner?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n", 31 + "$types": [ 32 + "app.bsky.actor.profile" 33 + ] 34 + }, 35 + { 36 + "name": "app.bsky.feed.like", 37 + "description": "Bluesky like records", 38 + "service": "bsky.app", 39 + "sampleRecords": [ 40 + { 41 + "$type": "app.bsky.feed.like", 42 + "subject": { 43 + "cid": "bafyreigdkn5773gshisfjoawof5aahcetkmwvc4pjjgo4xv3wu23ownss4", 44 + "uri": "at://did:plc:x56l2n7i7babgdzqul4bd433/app.bsky.feed.post/3lvqn6sjvtc2u" 45 + }, 46 + "createdAt": "2025-08-06T17:07:51.363Z" 47 + }, 48 + { 49 + "$type": "app.bsky.feed.like", 50 + "subject": { 51 + "cid": "bafyreihunfswi26wp276moerpjfyaoxa3qfcyxfk5aiohk5kitkhjw2nye", 52 + "uri": "at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lvqfhz6tyk2z" 53 + }, 54 + "createdAt": "2025-08-06T15:09:06.729Z" 55 + }, 56 + { 57 + "$type": "app.bsky.feed.like", 58 + "subject": { 59 + "cid": "bafyreibcvqmt6cnhx3gwhji7ljltsncah2lf6rlqc4wwopxqz7r5oyyryy", 60 + "uri": "at://did:plc:pkeo4mm3uwnik45y3tgjc5cs/app.bsky.feed.post/3lvqicp2fvb2z" 61 + }, 62 + "createdAt": "2025-08-06T15:00:41.448Z" 63 + } 64 + ], 65 + "generatedTypes": "export interface AppBskyFeedLike {\n $type: 'app.bsky.feed.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 66 + "$types": [ 67 + "app.bsky.feed.like" 68 + ] 69 + }, 70 + { 71 + "name": "app.bsky.feed.post", 72 + "description": "Bluesky posts", 73 + "service": "bsky.app", 74 + "sampleRecords": [ 75 + { 76 + "text": "yep shoulda realized the graze turbostream would only include bluesky records", 77 + "$type": "app.bsky.feed.post", 78 + "langs": [ 79 + "en" 80 + ], 81 + "createdAt": "2025-08-06T14:08:17.583Z" 82 + }, 83 + { 84 + "text": "this is a test", 85 + "$type": "app.bsky.feed.post", 86 + "langs": [ 87 + "en" 88 + ], 89 + "createdAt": "2025-08-06T14:04:21.632Z" 90 + }, 91 + { 92 + "text": "Is there not merit to 'sign in with atproto'? The decentralized identity is a major selling point of the protocol, not just the usage of lexicon.", 93 + "$type": "app.bsky.feed.post", 94 + "langs": [ 95 + "en" 96 + ], 97 + "reply": { 98 + "root": { 99 + "cid": "bafyreigxgudy4k2zvpikpnpqxwbwmkb6whfzkefr2rovd4y7pcppoggj7y", 100 + "uri": "at://did:plc:nmc77zslrwafxn75j66mep6o/app.bsky.feed.post/3lvpyn2gacc2e" 101 + }, 102 + "parent": { 103 + "cid": "bafyreigxgudy4k2zvpikpnpqxwbwmkb6whfzkefr2rovd4y7pcppoggj7y", 104 + "uri": "at://did:plc:nmc77zslrwafxn75j66mep6o/app.bsky.feed.post/3lvpyn2gacc2e" 105 + } 106 + }, 107 + "createdAt": "2025-08-06T13:28:03.405Z" 108 + } 109 + ], 110 + "generatedTypes": "export interface AppBskyFeedPost {\n $type: 'app.bsky.feed.post';\n text?: string;\n langs?: string[];\n createdAt?: string;\n}\n", 111 + "$types": [ 112 + "app.bsky.feed.post" 113 + ] 114 + }, 115 + { 116 + "name": "app.bsky.feed.postgate", 117 + "description": "app.bsky.feed.postgate records", 118 + "service": "bsky.app", 119 + "sampleRecords": [ 120 + { 121 + "post": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3laub4w4obc2a", 122 + "$type": "app.bsky.feed.postgate", 123 + "createdAt": "2024-11-13T21:11:08.065Z", 124 + "embeddingRules": [ 125 + { 126 + "$type": "app.bsky.feed.postgate#disableRule" 127 + } 128 + ], 129 + "detachedEmbeddingUris": [] 130 + } 131 + ], 132 + "generatedTypes": "export interface AppBskyFeedPostgate {\n $type: 'app.bsky.feed.postgate';\n post?: string;\n createdAt?: string;\n embeddingRules?: Record<string, any>[];\n detachedEmbeddingUris?: any[];\n}\n", 133 + "$types": [ 134 + "app.bsky.feed.postgate" 135 + ] 136 + }, 137 + { 138 + "name": "app.bsky.feed.repost", 139 + "description": "Bluesky repost records", 140 + "service": "bsky.app", 141 + "sampleRecords": [ 142 + { 143 + "$type": "app.bsky.feed.repost", 144 + "subject": { 145 + "cid": "bafyreihwclfpjxtgkjdempzvvwo4ayj5ynm3aezry4ym3its6tdiy5kz3i", 146 + "uri": "at://did:plc:i6y3jdklpvkjvynvsrnqfdoq/app.bsky.feed.post/3lvofxjoqt22g" 147 + }, 148 + "createdAt": "2025-08-05T19:50:46.238Z" 149 + }, 150 + { 151 + "$type": "app.bsky.feed.repost", 152 + "subject": { 153 + "cid": "bafyreibcfwujyfz2fnpks62tujff5qxlqtfrndz7bllv3u4l7zp6xes6ka", 154 + "uri": "at://did:plc:sfjxpxxyvewb2zlxwoz2vduw/app.bsky.feed.post/3lvldmqr7fs2q" 155 + }, 156 + "createdAt": "2025-08-04T14:10:40.273Z" 157 + }, 158 + { 159 + "$type": "app.bsky.feed.repost", 160 + "subject": { 161 + "cid": "bafyreicxla5m4mw2ocmxwtsodwijcy7hlgedml2gbohpgvc5ftf2j2z3sa", 162 + "uri": "at://did:plc:tpg43qhh4lw4ksiffs4nbda3/app.bsky.feed.post/3lvizvvkvhk2d" 163 + }, 164 + "createdAt": "2025-08-03T21:17:44.210Z" 165 + } 166 + ], 167 + "generatedTypes": "export interface AppBskyFeedRepost {\n $type: 'app.bsky.feed.repost';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 168 + "$types": [ 169 + "app.bsky.feed.repost" 170 + ] 171 + }, 172 + { 173 + "name": "app.bsky.feed.threadgate", 174 + "description": "app.bsky.feed.threadgate records", 175 + "service": "bsky.app", 176 + "sampleRecords": [ 177 + { 178 + "post": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3laub4w4obc2a", 179 + "$type": "app.bsky.feed.threadgate", 180 + "allow": [ 181 + { 182 + "$type": "app.bsky.feed.threadgate#mentionRule" 183 + } 184 + ], 185 + "createdAt": "2024-11-13T21:11:08.361Z", 186 + "hiddenReplies": [] 187 + } 188 + ], 189 + "generatedTypes": "export interface AppBskyFeedThreadgate {\n $type: 'app.bsky.feed.threadgate';\n post?: string;\n allow?: Record<string, any>[];\n createdAt?: string;\n hiddenReplies?: any[];\n}\n", 190 + "$types": [ 191 + "app.bsky.feed.threadgate" 192 + ] 193 + }, 194 + { 195 + "name": "app.bsky.graph.block", 196 + "description": "Bluesky block relationships", 197 + "service": "bsky.app", 198 + "sampleRecords": [ 199 + { 200 + "$type": "app.bsky.graph.block", 201 + "subject": "did:plc:7tw5kcf6375mlghspilpmpix", 202 + "createdAt": "2025-07-23T22:11:19.502Z" 203 + }, 204 + { 205 + "$type": "app.bsky.graph.block", 206 + "subject": "did:plc:f6y7lzl5jslkfn7ta47e7to3", 207 + "createdAt": "2025-07-23T20:01:13.114Z" 208 + }, 209 + { 210 + "$type": "app.bsky.graph.block", 211 + "subject": "did:plc:efwg4pw7qdyuh3qgr5x454fd", 212 + "createdAt": "2025-06-27T18:04:36.550Z" 213 + } 214 + ], 215 + "generatedTypes": "export interface AppBskyGraphBlock {\n $type: 'app.bsky.graph.block';\n subject?: string;\n createdAt?: string;\n}\n", 216 + "$types": [ 217 + "app.bsky.graph.block" 218 + ] 219 + }, 220 + { 221 + "name": "app.bsky.graph.follow", 222 + "description": "Bluesky follow relationships", 223 + "service": "bsky.app", 224 + "sampleRecords": [ 225 + { 226 + "$type": "app.bsky.graph.follow", 227 + "subject": "did:plc:mdailwqaetwpqnysw6qllqwl", 228 + "createdAt": "2025-08-06T14:57:20.859Z" 229 + }, 230 + { 231 + "$type": "app.bsky.graph.follow", 232 + "subject": "did:plc:f2np526hugxvamu25t6l4y6e", 233 + "createdAt": "2025-08-05T21:41:26.560Z" 234 + }, 235 + { 236 + "$type": "app.bsky.graph.follow", 237 + "subject": "did:plc:7axcqwj4roha6mqpdhpdwczx", 238 + "createdAt": "2025-08-05T21:37:20.051Z" 239 + } 240 + ], 241 + "generatedTypes": "export interface AppBskyGraphFollow {\n $type: 'app.bsky.graph.follow';\n subject?: string;\n createdAt?: string;\n}\n", 242 + "$types": [ 243 + "app.bsky.graph.follow" 244 + ] 245 + }, 246 + { 247 + "name": "app.bsky.graph.list", 248 + "description": "app.bsky.graph.list records", 249 + "service": "bsky.app", 250 + "sampleRecords": [ 251 + { 252 + "name": "hehe comics", 253 + "$type": "app.bsky.graph.list", 254 + "purpose": "app.bsky.graph.defs#curatelist", 255 + "createdAt": "2025-08-04T15:44:09.398Z", 256 + "description": "these are fun" 257 + }, 258 + { 259 + "name": "What's up Boston", 260 + "$type": "app.bsky.graph.list", 261 + "purpose": "app.bsky.graph.defs#referencelist", 262 + "createdAt": "2025-08-04T15:15:39.837Z" 263 + }, 264 + { 265 + "name": "My domain is proof of my identity", 266 + "$type": "app.bsky.graph.list", 267 + "avatar": { 268 + "$type": "blob", 269 + "ref": { 270 + "$link": "bafkreie56qtqmtp5iu56zqqjkvwhvzqcudtsbetgs2joaowvuljbcluvze" 271 + }, 272 + "mimeType": "image/jpeg", 273 + "size": 941683 274 + }, 275 + "purpose": "app.bsky.graph.defs#curatelist", 276 + "createdAt": "2025-07-31T15:57:34.609Z", 277 + "description": "You know this is me because its tynanpurdy.com", 278 + "descriptionFacets": [ 279 + { 280 + "index": { 281 + "byteEnd": 46, 282 + "byteStart": 32 283 + }, 284 + "features": [ 285 + { 286 + "uri": "https://tynanpurdy.com", 287 + "$type": "app.bsky.richtext.facet#link" 288 + } 289 + ] 290 + } 291 + ] 292 + } 293 + ], 294 + "generatedTypes": "export interface AppBskyGraphList {\n $type: 'app.bsky.graph.list';\n name?: string;\n purpose?: string;\n createdAt?: string;\n description?: string;\n}\n", 295 + "$types": [ 296 + "app.bsky.graph.list" 297 + ] 298 + }, 299 + { 300 + "name": "app.bsky.graph.listitem", 301 + "description": "app.bsky.graph.listitem records", 302 + "service": "bsky.app", 303 + "sampleRecords": [ 304 + { 305 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lquir4dvpk2v", 306 + "$type": "app.bsky.graph.listitem", 307 + "subject": "did:plc:mdailwqaetwpqnysw6qllqwl", 308 + "createdAt": "2025-08-06T14:45:30.004Z" 309 + }, 310 + { 311 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lquir4dvpk2v", 312 + "$type": "app.bsky.graph.listitem", 313 + "subject": "did:plc:k2r4d4exuuord4sos4wavcoj", 314 + "createdAt": "2025-08-05T13:39:59.770Z" 315 + }, 316 + { 317 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lvljxc7e2r2j", 318 + "$type": "app.bsky.graph.listitem", 319 + "subject": "did:plc:v3n5wr27y6flohaycmcgsrij", 320 + "createdAt": "2025-08-04T19:01:10.319Z" 321 + } 322 + ], 323 + "generatedTypes": "export interface AppBskyGraphListitem {\n $type: 'app.bsky.graph.listitem';\n list?: string;\n subject?: string;\n createdAt?: string;\n}\n", 324 + "$types": [ 325 + "app.bsky.graph.listitem" 326 + ] 327 + }, 328 + { 329 + "name": "app.bsky.graph.starterpack", 330 + "description": "app.bsky.graph.starterpack records", 331 + "service": "bsky.app", 332 + "sampleRecords": [ 333 + { 334 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lvliedprgp2t", 335 + "name": "What's up Boston", 336 + "$type": "app.bsky.graph.starterpack", 337 + "feeds": [ 338 + { 339 + "cid": "bafyreigd75aawyao7dikal4bm634dxtgf7xp43msbywcx4ctcyqojjubni", 340 + "did": "did:web:skyfeed.me", 341 + "uri": "at://did:plc:r2mpjf3gz2ygfaodkzzzfddg/app.bsky.feed.generator/aaag6jgz6tfou", 342 + "labels": [], 343 + "viewer": {}, 344 + "creator": { 345 + "did": "did:plc:r2mpjf3gz2ygfaodkzzzfddg", 346 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:r2mpjf3gz2ygfaodkzzzfddg/bafkreidvm7mnr3mwg7lnktrpeebsr7tz5u4ic3acrdlnte7ojp45vrtmjm@jpeg", 347 + "handle": "boston.gov", 348 + "labels": [ 349 + { 350 + "cts": "2025-02-12T02:03:24.048Z", 351 + "src": "did:plc:m6adptn62dcahfaq34tce3j5", 352 + "uri": "did:plc:r2mpjf3gz2ygfaodkzzzfddg", 353 + "val": "joined-feb-c", 354 + "ver": 1 355 + }, 356 + { 357 + "cts": "2025-06-06T17:54:44.623Z", 358 + "src": "did:plc:fqfzpua2rp5io5nmxcixvdvm", 359 + "uri": "did:plc:r2mpjf3gz2ygfaodkzzzfddg", 360 + "val": "voting-for-kodos", 361 + "ver": 1 362 + } 363 + ], 364 + "viewer": { 365 + "muted": false, 366 + "blockedBy": false, 367 + "following": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.follow/3lvlj4yyafh2w" 368 + }, 369 + "createdAt": "2024-02-09T16:18:11.855Z", 370 + "indexedAt": "2025-04-29T23:55:10.143Z", 371 + "associated": { 372 + "activitySubscription": { 373 + "allowSubscriptions": "followers" 374 + } 375 + }, 376 + "description": "The official Bluesky account of the City of Boston. For non-emergency services, please call 311. \n\nBoston.gov", 377 + "displayName": "City of Boston", 378 + "verification": { 379 + "verifications": [ 380 + { 381 + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lu23uowywx2m", 382 + "issuer": "did:plc:z72i7hdynmk6r22z27h6tvur", 383 + "isValid": true, 384 + "createdAt": "2025-07-15T23:51:42.492Z" 385 + } 386 + ], 387 + "verifiedStatus": "valid", 388 + "trustedVerifierStatus": "none" 389 + } 390 + }, 391 + "indexedAt": "2024-11-27T19:48:59.425Z", 392 + "likeCount": 21, 393 + "description": "Official City of Boston accounts representing departments, cabinets, commissions and more teams working to make Boston a home for everyone.", 394 + "displayName": "City of Boston" 395 + } 396 + ], 397 + "createdAt": "2025-08-04T15:15:40.040Z", 398 + "updatedAt": "2025-08-04T15:36:11.792Z" 399 + } 400 + ], 401 + "generatedTypes": "export interface AppBskyGraphStarterpack {\n $type: 'app.bsky.graph.starterpack';\n list?: string;\n name?: string;\n feeds?: Record<string, any>[];\n createdAt?: string;\n updatedAt?: string;\n}\n", 402 + "$types": [ 403 + "app.bsky.graph.starterpack" 404 + ] 405 + }, 406 + { 407 + "name": "app.bsky.graph.verification", 408 + "description": "app.bsky.graph.verification records", 409 + "service": "bsky.app", 410 + "sampleRecords": [ 411 + { 412 + "$type": "app.bsky.graph.verification", 413 + "handle": "goodwillhinton.com", 414 + "subject": "did:plc:ek7ua4ev2a35cdgehdxo4jsg", 415 + "createdAt": "2025-07-10T19:57:12.524Z", 416 + "displayName": "Good Will Hinton" 417 + }, 418 + { 419 + "$type": "app.bsky.graph.verification", 420 + "handle": "jacknicolaus.bsky.social", 421 + "subject": "did:plc:5ldqrqjevj5q5s5dyc3w6q2a", 422 + "createdAt": "2025-07-10T19:55:00.870Z", 423 + "displayName": "Jack Purdy" 424 + }, 425 + { 426 + "$type": "app.bsky.graph.verification", 427 + "handle": "jennpurdy.bsky.social", 428 + "subject": "did:plc:7tai7ouufljuzlhjajpiojyw", 429 + "createdAt": "2025-07-10T19:54:44.028Z", 430 + "displayName": "Jenn Purdy" 431 + } 432 + ], 433 + "generatedTypes": "export interface AppBskyGraphVerification {\n $type: 'app.bsky.graph.verification';\n handle?: string;\n subject?: string;\n createdAt?: string;\n displayName?: string;\n}\n", 434 + "$types": [ 435 + "app.bsky.graph.verification" 436 + ] 437 + }, 438 + { 439 + "name": "app.popsky.list", 440 + "description": "app.popsky.list records", 441 + "service": "unknown", 442 + "sampleRecords": [ 443 + { 444 + "name": "Played Games", 445 + "$type": "app.popsky.list", 446 + "authorDid": "did:plc:6ayddqghxhciedbaofoxkcbs", 447 + "createdAt": "2025-07-29T18:45:43.158Z", 448 + "indexedAt": "2025-07-29T18:45:43.158Z", 449 + "description": "" 450 + }, 451 + { 452 + "name": "The good life", 453 + "tags": [], 454 + "$type": "app.popsky.list", 455 + "ordered": false, 456 + "createdAt": "2025-07-29T18:42:13.706Z", 457 + "description": "" 458 + }, 459 + { 460 + "name": "Listened Albums & EPs", 461 + "$type": "app.popsky.list", 462 + "authorDid": "did:plc:6ayddqghxhciedbaofoxkcbs", 463 + "createdAt": "2025-07-29T18:40:45.138Z", 464 + "indexedAt": "2025-07-29T18:40:45.138Z", 465 + "description": "" 466 + } 467 + ], 468 + "generatedTypes": "export interface AppPopskyList {\n $type: 'app.popsky.list';\n name?: string;\n authorDid?: string;\n createdAt?: string;\n indexedAt?: string;\n description?: string;\n}\n", 469 + "$types": [ 470 + "app.popsky.list" 471 + ] 472 + }, 473 + { 474 + "name": "app.popsky.listItem", 475 + "description": "app.popsky.listItem records", 476 + "service": "unknown", 477 + "sampleRecords": [ 478 + { 479 + "$type": "app.popsky.listItem", 480 + "addedAt": "2025-08-04T20:56:17.962Z", 481 + "listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3lv4qhdmaoc2f", 482 + "identifiers": { 483 + "isbn10": "1991152310", 484 + "isbn13": "9781991152312" 485 + }, 486 + "creativeWorkType": "book" 487 + }, 488 + { 489 + "$type": "app.popsky.listItem", 490 + "addedAt": "2025-08-04T20:56:14.809Z", 491 + "listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3lv4r46l6ck2f", 492 + "identifiers": { 493 + "isbn10": "1991152310", 494 + "isbn13": "9781991152312" 495 + }, 496 + "creativeWorkType": "book" 497 + }, 498 + { 499 + "$type": "app.popsky.listItem", 500 + "addedAt": "2025-08-04T20:55:30.197Z", 501 + "listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3ltyvzvrlkk2k", 502 + "identifiers": { 503 + "isbn10": "1988575060", 504 + "isbn13": "9781988575063" 505 + }, 506 + "creativeWorkType": "book" 507 + } 508 + ], 509 + "generatedTypes": "export interface AppPopskyListItem {\n $type: 'app.popsky.listItem';\n addedAt?: string;\n listUri?: string;\n identifiers?: Record<string, any>;\n creativeWorkType?: string;\n}\n", 510 + "$types": [ 511 + "app.popsky.listItem" 512 + ] 513 + }, 514 + { 515 + "name": "app.popsky.profile", 516 + "description": "app.popsky.profile records", 517 + "service": "unknown", 518 + "sampleRecords": [ 519 + { 520 + "$type": "app.popsky.profile", 521 + "createdAt": "2025-07-29T18:29:43.645Z", 522 + "description": "", 523 + "displayName": "Tynan Purdy" 524 + } 525 + ], 526 + "generatedTypes": "export interface AppPopskyProfile {\n $type: 'app.popsky.profile';\n createdAt?: string;\n description?: string;\n displayName?: string;\n}\n", 527 + "$types": [ 528 + "app.popsky.profile" 529 + ] 530 + }, 531 + { 532 + "name": "app.popsky.review", 533 + "description": "app.popsky.review records", 534 + "service": "unknown", 535 + "sampleRecords": [ 536 + { 537 + "tags": [], 538 + "$type": "app.popsky.review", 539 + "facets": [], 540 + "rating": 10, 541 + "createdAt": "2025-07-29T18:45:40.559Z", 542 + "isRevisit": false, 543 + "reviewText": "", 544 + "identifiers": { 545 + "igdbId": "28512" 546 + }, 547 + "containsSpoilers": false, 548 + "creativeWorkType": "video_game" 549 + }, 550 + { 551 + "tags": [], 552 + "$type": "app.popsky.review", 553 + "facets": [], 554 + "rating": 9, 555 + "createdAt": "2024-01-05T00:00:00.000Z", 556 + "isRevisit": false, 557 + "reviewText": "", 558 + "identifiers": { 559 + "isbn13": "9781797114613" 560 + }, 561 + "containsSpoilers": false, 562 + "creativeWorkType": "book" 563 + }, 564 + { 565 + "tags": [], 566 + "$type": "app.popsky.review", 567 + "facets": [], 568 + "rating": 8, 569 + "createdAt": "2024-05-02T00:00:00.000Z", 570 + "isRevisit": false, 571 + "reviewText": "", 572 + "identifiers": { 573 + "isbn13": "9781415959138" 574 + }, 575 + "containsSpoilers": false, 576 + "creativeWorkType": "book" 577 + } 578 + ], 579 + "generatedTypes": "export interface AppPopskyReview {\n $type: 'app.popsky.review';\n tags?: any[];\n facets?: any[];\n rating?: number;\n createdAt?: string;\n isRevisit?: boolean;\n reviewText?: string;\n identifiers?: Record<string, any>;\n containsSpoilers?: boolean;\n creativeWorkType?: string;\n}\n", 580 + "$types": [ 581 + "app.popsky.review" 582 + ] 583 + }, 584 + { 585 + "name": "app.rocksky.album", 586 + "description": "app.rocksky.album records", 587 + "service": "unknown", 588 + "sampleRecords": [ 589 + { 590 + "year": 2025, 591 + "$type": "app.rocksky.album", 592 + "title": "Heartbreak Hysteria", 593 + "artist": "Sawyer Hill", 594 + "albumArt": { 595 + "$type": "blob", 596 + "ref": { 597 + "$link": "bafkreibaol5obcwhudbcli2o33nh7ats764hqnkpfxjxqn3ykjeb65ht5q" 598 + }, 599 + "mimeType": "image/jpeg", 600 + "size": 38011 601 + }, 602 + "createdAt": "2025-07-12T20:41:54.087Z", 603 + "releaseDate": "2025-04-18T00:00:00.000Z" 604 + }, 605 + { 606 + "year": 2023, 607 + "$type": "app.rocksky.album", 608 + "title": "I Love You, Iโ€™m Trying", 609 + "artist": "grandson", 610 + "albumArt": { 611 + "$type": "blob", 612 + "ref": { 613 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 614 + }, 615 + "mimeType": "image/jpeg", 616 + "size": 266870 617 + }, 618 + "createdAt": "2025-07-12T20:39:46.979Z", 619 + "releaseDate": "2023-05-05T00:00:00.000Z" 620 + }, 621 + { 622 + "year": 2025, 623 + "$type": "app.rocksky.album", 624 + "title": "june", 625 + "artist": "DE'WAYNE", 626 + "albumArt": { 627 + "$type": "blob", 628 + "ref": { 629 + "$link": "bafkreihiydajfy66mbmk4g2cc4ymvcj5jkba2rxrnucxjcxl63rvch37ee" 630 + }, 631 + "mimeType": "image/jpeg", 632 + "size": 78882 633 + }, 634 + "createdAt": "2025-07-12T20:36:54.769Z", 635 + "releaseDate": "2025-05-14T00:00:00.000Z" 636 + } 637 + ], 638 + "generatedTypes": "export interface AppRockskyAlbum {\n $type: 'app.rocksky.album';\n year?: number;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n createdAt?: string;\n releaseDate?: string;\n}\n", 639 + "$types": [ 640 + "app.rocksky.album" 641 + ] 642 + }, 643 + { 644 + "name": "app.rocksky.artist", 645 + "description": "app.rocksky.artist records", 646 + "service": "unknown", 647 + "sampleRecords": [ 648 + { 649 + "name": "Sawyer Hill", 650 + "$type": "app.rocksky.artist", 651 + "picture": { 652 + "$type": "blob", 653 + "ref": { 654 + "$link": "bafkreibndm5e5idr7o26wlci5twbcavtjrfatg4eeal7beieqxvqyipfnu" 655 + }, 656 + "mimeType": "image/jpeg", 657 + "size": 92121 658 + }, 659 + "createdAt": "2025-07-12T20:41:51.867Z" 660 + }, 661 + { 662 + "name": "DE'WAYNE", 663 + "$type": "app.rocksky.artist", 664 + "picture": { 665 + "$type": "blob", 666 + "ref": { 667 + "$link": "bafkreihvir6gp4ls2foh7grfx7cgouc4scxc7ubhk535ig7nbrvtpscf7u" 668 + }, 669 + "mimeType": "image/jpeg", 670 + "size": 167229 671 + }, 672 + "createdAt": "2025-07-12T20:36:52.320Z" 673 + }, 674 + { 675 + "name": "Bryce Fox", 676 + "$type": "app.rocksky.artist", 677 + "picture": { 678 + "$type": "blob", 679 + "ref": { 680 + "$link": "bafkreigx4a5reezucwsujfuyv27lun5xkavjtgkzvifknrsblh3jnvznhi" 681 + }, 682 + "mimeType": "image/jpeg", 683 + "size": 119015 684 + }, 685 + "createdAt": "2025-07-12T20:34:10.891Z" 686 + } 687 + ], 688 + "generatedTypes": "export interface AppRockskyArtist {\n $type: 'app.rocksky.artist';\n name?: string;\n picture?: Record<string, any>;\n createdAt?: string;\n}\n", 689 + "$types": [ 690 + "app.rocksky.artist" 691 + ] 692 + }, 693 + { 694 + "name": "app.rocksky.like", 695 + "description": "app.rocksky.like records", 696 + "service": "unknown", 697 + "sampleRecords": [ 698 + { 699 + "$type": "app.rocksky.like", 700 + "subject": { 701 + "cid": "bafyreiejzq3p7bqjuo6sgt4x3rwojnvu4jajrolzread7eqpzo7jryoega", 702 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lmhvldhhgs2r" 703 + }, 704 + "createdAt": "2025-04-17T14:30:30.388Z" 705 + }, 706 + { 707 + "$type": "app.rocksky.like", 708 + "subject": { 709 + "cid": "bafyreihvboqggljg677rxl6xxr2mqudlefmixhkrvf2gltmpzbhyzduqba", 710 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lmhqwxjxek2m" 711 + }, 712 + "createdAt": "2025-04-15T19:25:44.473Z" 713 + }, 714 + { 715 + "$type": "app.rocksky.like", 716 + "subject": { 717 + "cid": "bafyreiah4sm2jjrgcnqhmfaeze44th3svwlqbftpqepi67c2i6khzjqwya", 718 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lloqnprwwk2c" 719 + }, 720 + "createdAt": "2025-03-31T16:38:14.223Z" 721 + } 722 + ], 723 + "generatedTypes": "export interface AppRockskyLike {\n $type: 'app.rocksky.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 724 + "$types": [ 725 + "app.rocksky.like" 726 + ] 727 + }, 728 + { 729 + "name": "app.rocksky.scrobble", 730 + "description": "app.rocksky.scrobble records", 731 + "service": "unknown", 732 + "sampleRecords": [ 733 + { 734 + "year": 2025, 735 + "$type": "app.rocksky.scrobble", 736 + "album": "Heartbreak Hysteria", 737 + "title": "One Shot", 738 + "artist": "Sawyer Hill", 739 + "albumArt": { 740 + "$type": "blob", 741 + "ref": { 742 + "$link": "bafkreibaol5obcwhudbcli2o33nh7ats764hqnkpfxjxqn3ykjeb65ht5q" 743 + }, 744 + "mimeType": "image/jpeg", 745 + "size": 38011 746 + }, 747 + "duration": 198380, 748 + "createdAt": "2025-07-12T20:41:58.353Z", 749 + "discNumber": 1, 750 + "albumArtist": "Sawyer Hill", 751 + "releaseDate": "2025-04-18T00:00:00.000Z", 752 + "spotifyLink": "https://open.spotify.com/track/4yiiRbfHkqhCj0ChW62ASx", 753 + "trackNumber": 2 754 + }, 755 + { 756 + "year": 2023, 757 + "$type": "app.rocksky.scrobble", 758 + "album": "I Love You, Iโ€™m Trying", 759 + "title": "Something To Hide", 760 + "artist": "grandson", 761 + "albumArt": { 762 + "$type": "blob", 763 + "ref": { 764 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 765 + }, 766 + "mimeType": "image/jpeg", 767 + "size": 266870 768 + }, 769 + "duration": 119047, 770 + "createdAt": "2025-07-12T20:39:51.055Z", 771 + "discNumber": 1, 772 + "albumArtist": "grandson", 773 + "releaseDate": "2023-05-05T00:00:00.000Z", 774 + "spotifyLink": "https://open.spotify.com/track/1rjZicKSpIJ3WYffIK9Fuy", 775 + "trackNumber": 3 776 + }, 777 + { 778 + "year": 2025, 779 + "$type": "app.rocksky.scrobble", 780 + "album": "june", 781 + "title": "june", 782 + "artist": "DE'WAYNE", 783 + "albumArt": { 784 + "$type": "blob", 785 + "ref": { 786 + "$link": "bafkreihiydajfy66mbmk4g2cc4ymvcj5jkba2rxrnucxjcxl63rvch37ee" 787 + }, 788 + "mimeType": "image/jpeg", 789 + "size": 78882 790 + }, 791 + "duration": 168000, 792 + "createdAt": "2025-07-12T20:36:58.633Z", 793 + "discNumber": 1, 794 + "albumArtist": "DE'WAYNE", 795 + "releaseDate": "2025-05-14T00:00:00.000Z", 796 + "spotifyLink": "https://open.spotify.com/track/6PBJfoq40a8gsUULbn0oyG", 797 + "trackNumber": 1 798 + } 799 + ], 800 + "generatedTypes": "export interface AppRockskyScrobble {\n $type: 'app.rocksky.scrobble';\n year?: number;\n album?: string;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n duration?: number;\n createdAt?: string;\n discNumber?: number;\n albumArtist?: string;\n releaseDate?: string;\n spotifyLink?: string;\n trackNumber?: number;\n}\n", 801 + "$types": [ 802 + "app.rocksky.scrobble" 803 + ] 804 + }, 805 + { 806 + "name": "app.rocksky.song", 807 + "description": "app.rocksky.song records", 808 + "service": "unknown", 809 + "sampleRecords": [ 810 + { 811 + "year": 2023, 812 + "$type": "app.rocksky.song", 813 + "album": "I Love You, Iโ€™m Trying", 814 + "title": "Stuck Here With Me", 815 + "artist": "grandson", 816 + "albumArt": { 817 + "$type": "blob", 818 + "ref": { 819 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 820 + }, 821 + "mimeType": "image/jpeg", 822 + "size": 266870 823 + }, 824 + "duration": 235930, 825 + "createdAt": "2025-07-12T20:50:45.291Z", 826 + "discNumber": 1, 827 + "albumArtist": "grandson", 828 + "releaseDate": "2023-05-05T00:00:00.000Z", 829 + "spotifyLink": "https://open.spotify.com/track/29FvUqoMbmQ50fdHlgs5om", 830 + "trackNumber": 12 831 + }, 832 + { 833 + "year": 2023, 834 + "$type": "app.rocksky.song", 835 + "album": "I Love You, Iโ€™m Trying", 836 + "title": "Heather", 837 + "artist": "grandson", 838 + "albumArt": { 839 + "$type": "blob", 840 + "ref": { 841 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 842 + }, 843 + "mimeType": "image/jpeg", 844 + "size": 266870 845 + }, 846 + "duration": 198468, 847 + "createdAt": "2025-07-12T20:49:46.259Z", 848 + "discNumber": 1, 849 + "albumArtist": "grandson", 850 + "releaseDate": "2023-05-05T00:00:00.000Z", 851 + "spotifyLink": "https://open.spotify.com/track/05jgkkHC2o5edhP92u9pgU", 852 + "trackNumber": 11 853 + }, 854 + { 855 + "year": 2023, 856 + "$type": "app.rocksky.song", 857 + "album": "I Love You, Iโ€™m Trying", 858 + "title": "I Will Be Here When Youโ€™re Ready To Wake Up (feat. Wafia)", 859 + "artist": "grandson, Wafia", 860 + "albumArt": { 861 + "$type": "blob", 862 + "ref": { 863 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 864 + }, 865 + "mimeType": "image/jpeg", 866 + "size": 266870 867 + }, 868 + "duration": 60604, 869 + "createdAt": "2025-07-12T20:48:46.161Z", 870 + "discNumber": 1, 871 + "albumArtist": "grandson", 872 + "releaseDate": "2023-05-05T00:00:00.000Z", 873 + "spotifyLink": "https://open.spotify.com/track/2fQQZQpze9c44ebtCYx8Jl", 874 + "trackNumber": 10 875 + } 876 + ], 877 + "generatedTypes": "export interface AppRockskySong {\n $type: 'app.rocksky.song';\n year?: number;\n album?: string;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n duration?: number;\n createdAt?: string;\n discNumber?: number;\n albumArtist?: string;\n releaseDate?: string;\n spotifyLink?: string;\n trackNumber?: number;\n}\n", 878 + "$types": [ 879 + "app.rocksky.song" 880 + ] 881 + }, 882 + { 883 + "name": "blue.flashes.actor.profile", 884 + "description": "blue.flashes.actor.profile records", 885 + "service": "unknown", 886 + "sampleRecords": [ 887 + { 888 + "$type": "blue.flashes.actor.profile", 889 + "createdAt": "2025-07-28T18:24:43.072Z", 890 + "showFeeds": true, 891 + "showLikes": false, 892 + "showLists": true, 893 + "showMedia": true, 894 + "enablePortfolio": false, 895 + "portfolioLayout": "grid", 896 + "allowRawDownload": false 897 + } 898 + ], 899 + "generatedTypes": "export interface BlueFlashesActorProfile {\n $type: 'blue.flashes.actor.profile';\n createdAt?: string;\n showFeeds?: boolean;\n showLikes?: boolean;\n showLists?: boolean;\n showMedia?: boolean;\n enablePortfolio?: boolean;\n portfolioLayout?: string;\n allowRawDownload?: boolean;\n}\n", 900 + "$types": [ 901 + "blue.flashes.actor.profile" 902 + ] 903 + }, 904 + { 905 + "name": "blue.linkat.board", 906 + "description": "blue.linkat.board records", 907 + "service": "unknown", 908 + "sampleRecords": [ 909 + { 910 + "$type": "blue.linkat.board", 911 + "cards": [ 912 + { 913 + "url": "https://tynanpurdy.com", 914 + "text": "Portfolio", 915 + "emoji": "๐Ÿ”—" 916 + }, 917 + { 918 + "url": "https://blog.tynanpurdy.com", 919 + "text": "Blog", 920 + "emoji": "๐Ÿ“ฐ" 921 + }, 922 + { 923 + "url": "https://github.com/tynanpurdy", 924 + "text": "GitHub", 925 + "emoji": "" 926 + }, 927 + { 928 + "url": "https://www.linkedin.com/in/tynanpurdy", 929 + "text": "LinkedIn", 930 + "emoji": "๐Ÿ‘”" 931 + }, 932 + { 933 + "url": "https://mastodon.social/@tynanpurdy.com@bsky.brid.gy", 934 + "text": "Mastodon (Bridged)", 935 + "emoji": "" 936 + } 937 + ] 938 + } 939 + ], 940 + "generatedTypes": "export interface BlueLinkatBoard {\n $type: 'blue.linkat.board';\n cards?: Record<string, any>[];\n}\n", 941 + "$types": [ 942 + "blue.linkat.board" 943 + ] 944 + }, 945 + { 946 + "name": "buzz.bookhive.book", 947 + "description": "buzz.bookhive.book records", 948 + "service": "unknown", 949 + "sampleRecords": [ 950 + { 951 + "$type": "buzz.bookhive.book", 952 + "cover": { 953 + "$type": "blob", 954 + "ref": { 955 + "$link": "bafkreih7cdewtoxyo7fpl4x6bsnf3edjs2jg26jdy23opkzz7eug6zqff4" 956 + }, 957 + "mimeType": "image/jpeg", 958 + "size": 52491 959 + }, 960 + "title": "A โ€‹Court of Silver Flames", 961 + "hiveId": "bk_J1U6l2ckLEM4sALVBxUp", 962 + "status": "buzz.bookhive.defs#finished", 963 + "authors": "Sarah J. Maas", 964 + "createdAt": "2025-07-15T03:45:29.303Z" 965 + }, 966 + { 967 + "$type": "buzz.bookhive.book", 968 + "cover": { 969 + "$type": "blob", 970 + "ref": { 971 + "$link": "bafkreigsicpiwolxv7ap2iaaljy356bzjwf6dtuvgzwahz4uokz5kz6k6m" 972 + }, 973 + "mimeType": "image/jpeg", 974 + "size": 124025 975 + }, 976 + "title": "When We're in Charge: The Next Generationโ€™s Guide to Leadership", 977 + "hiveId": "bk_1D28ImhUcffLrWt8G9UW", 978 + "status": "buzz.bookhive.defs#wantToRead", 979 + "authors": "Amanda Litman", 980 + "createdAt": "2025-05-17T20:03:38.336Z" 981 + }, 982 + { 983 + "$type": "buzz.bookhive.book", 984 + "cover": { 985 + "$type": "blob", 986 + "ref": { 987 + "$link": "bafkreig5s2k5s42ccbdren2sfdasxyq2e2er7qcb6qs2escdhlt7lsuxtm" 988 + }, 989 + "mimeType": "image/jpeg", 990 + "size": 127596 991 + }, 992 + "title": "Dune", 993 + "hiveId": "bk_GUShjG8U9l93XqIrGiKV", 994 + "status": "buzz.bookhive.defs#finished", 995 + "authors": "Frank Herbert", 996 + "createdAt": "2025-05-17T19:24:44.640Z" 997 + } 998 + ], 999 + "generatedTypes": "export interface BuzzBookhiveBook {\n $type: 'buzz.bookhive.book';\n cover?: Record<string, any>;\n title?: string;\n hiveId?: string;\n status?: string;\n authors?: string;\n createdAt?: string;\n}\n", 1000 + "$types": [ 1001 + "buzz.bookhive.book" 1002 + ] 1003 + }, 1004 + { 1005 + "name": "chat.bsky.actor.declaration", 1006 + "description": "chat.bsky.actor.declaration records", 1007 + "service": "unknown", 1008 + "sampleRecords": [ 1009 + { 1010 + "$type": "chat.bsky.actor.declaration", 1011 + "allowIncoming": "following" 1012 + } 1013 + ], 1014 + "generatedTypes": "export interface ChatBskyActorDeclaration {\n $type: 'chat.bsky.actor.declaration';\n allowIncoming?: string;\n}\n", 1015 + "$types": [ 1016 + "chat.bsky.actor.declaration" 1017 + ] 1018 + }, 1019 + { 1020 + "name": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog", 1021 + "description": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog records", 1022 + "service": "unknown", 1023 + "sampleRecords": [ 1024 + { 1025 + "id": "leaf:ypcj310ntdfmzpg670b5j29xe3034dnh3kcvk76p5amwwy35hqt0", 1026 + "$type": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog" 1027 + } 1028 + ], 1029 + "generatedTypes": "export interface ChatRoomy01JPNX7AA9BSM6TY2GWW1TR5V7Catalog {\n $type: 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog';\n id?: string;\n}\n", 1030 + "$types": [ 1031 + "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog" 1032 + ] 1033 + }, 1034 + { 1035 + "name": "chat.roomy.profile", 1036 + "description": "chat.roomy.profile records", 1037 + "service": "unknown", 1038 + "sampleRecords": [ 1039 + { 1040 + "$type": "chat.roomy.profile", 1041 + "accountId": "co_zhgWve43YBnn266mcVACjUuVG3d", 1042 + "profileId": "co_zDFNXMtNLkqorucsoFX89zb9xKq" 1043 + } 1044 + ], 1045 + "generatedTypes": "export interface ChatRoomyProfile {\n $type: 'chat.roomy.profile';\n accountId?: string;\n profileId?: string;\n}\n", 1046 + "$types": [ 1047 + "chat.roomy.profile" 1048 + ] 1049 + }, 1050 + { 1051 + "name": "com.germnetwork.keypackage", 1052 + "description": "com.germnetwork.keypackage records", 1053 + "service": "unknown", 1054 + "sampleRecords": [ 1055 + { 1056 + "$type": "com.germnetwork.keypackage", 1057 + "anchorHello": "AHHqxtKPJc9RLZyPZVexLVS3trjVRRE/JYDxDyB71KyTBYB9vQL1yyHSlNytf+5OWb0S1h5WM5F43uJuty8XTAT/AaUAAAOKVay8GnbTuqQtVPA11MVoVY61HW8e5vnxDQmdmVT9+wICAAAB/wE5AAEABQABAAMgVyrTJ4GcogoE02O68mMrGrmhuRXIPxYmzJ4GoUaVPDMgiVabYjh9CTZ/Kiefa/IQT9htiTEzvxAzR+hrw0b4qgUgGALXa4Kf0riu5j1k+gqYxQOvpzihscrm7IIAoaVdo5cAASEDilWsvBp207qkLVTwNdTFaFWOtR1vHub58Q0JnZlU/fsCAAEKAAIABwAFAAEAAwAAAgABAQAAAABokkwRAAAAAGpzf5EAQEDrZKju/Dqxnmcnpfc/tbClFyaA7ojWK9uVH7ZFieHdcEwNSRwtdi3pYJcSselbyUpJXRsc0jlOq428bTAQIx0IAEBAk2n8vRJ32A+09QEbh7gIjAdarFwAXH0JUBf3h2fY7LcTpopvo68IaNPDtf9aZBgJukfWZKzrQzSK8pWFAZUiCQBvVL5Mcq1KiCMH26+O2m/3+6XaKedsXm8BhHxzM9rfzUVJe1YbNMxv+SqRaDUaX7AACEaTw0m4KSjNVj7OPyYC" 1058 + } 1059 + ], 1060 + "generatedTypes": "export interface ComGermnetworkKeypackage {\n $type: 'com.germnetwork.keypackage';\n anchorHello?: string;\n}\n", 1061 + "$types": [ 1062 + "com.germnetwork.keypackage" 1063 + ] 1064 + }, 1065 + { 1066 + "name": "com.whtwnd.blog.entry", 1067 + "description": "com.whtwnd.blog.entry records", 1068 + "service": "unknown", 1069 + "sampleRecords": [ 1070 + { 1071 + "$type": "com.whtwnd.blog.entry", 1072 + "theme": "github-light", 1073 + "title": "Historic Atlanta Neighborhood Brand (Project Test)", 1074 + "content": "![image](https://framerusercontent.com/images/Ap45SgGnBdON8exvIwcXYpsdy8.jpg)\n\n> ๐Ÿ“— The team at Design Bloc worked closely with the residents of the historic Hunter Hills community to design a neighborhood brand that accurately represents their rich history. Design Bloc staff conducted extensive ethnographic research to understand the social, environmental, and material considerations for a representative brand.\n\n![image](https://framerusercontent.com/images/81IRCb1XebTy2hdaYwrgtRPFdLY.jpg)\n\n# Ethnographic Research\n\nOur journey begins with empathy. As one of the first planned black neighborhoods in Atlanta, Hunter Hills carries a long and significant history that must be preserved in its representation.\n\n> _The Hunter Hills brand should_ **reinforce the historic values of a unified community.**\n\n`literature reviews` helped us understand the context.\n\n`neighborhood walks` helped us understand the landscape.\n\n`resident interviews` helped us understand the experience.\n\n![image](https://framerusercontent.com/images/dpMAPMQKiJDIBHeBRo81pwV2Z1A.jpg)\n\n# Designing With the Community\n\nWe met with the Hunter Hills Neighborhood Association after each of several rounds of logo ideation, gathering feedback to lead us to the best final logo.\n\n![image](https://framerusercontent.com/images/gVlBWy07LHsZtPKWVpBlyQVki5o.jpg)\n\nStaff generated dozens of sketch thumbnails.\n\n![image](https://framerusercontent.com/images/9c419zGznQFCIypyYJjmxWWMmg.png)\n\nI iterated on another staffโ€™s sketch to arrive at the final logo on the right.\n\n# Collaborative InDesign\n\nI prepared a template file for our staff to create assigned pages of the final book.\n\n![image](https://framerusercontent.com/images/aKUnGNeWZKE9YMLNYiVHsRPzI0.png)\n\nPage plan with proportional grid fields and measured type\n\n![image](https://framerusercontent.com/images/D6wDwYfhkEXIaJeWdYLmiEss1U.png)\n\nPredefined text styles designed to fit in the grid and integrate with InDesignโ€™s TOC feature\n\n![image](https://framerusercontent.com/images/91Ea2Y2GzSMpdc4NAvOinG3Mr4.png)\n\nStandard page layouts for collaborative consistency\n\n# The Handoff\n\n![image](https://framerusercontent.com/images/wRzUdVzX9cVMmshSso1s8Y74.jpg)\n\n# Acknowledgements\n\nThanks to my team mates: Mars Lovelace, Hannan Abdi, Cole Campbell, Jordan Lym, Hunter Schaufel, Margaret Lu\nThanks to our leads: Shawn Harris, Michael Flanigan, Wayne Li\nThanks to the residents: Char Johnson, Lisa Reyes, Alfred Tucker, everyone from the Hunter Hills Neighborhood Association, and all the neighbors that contributed via interviews and workshops", 1075 + "createdAt": "2025-07-21T20:00:36.906Z", 1076 + "visibility": "public" 1077 + }, 1078 + { 1079 + "$type": "com.whtwnd.blog.entry", 1080 + "theme": "github-light", 1081 + "title": "The experiment continues!", 1082 + "content": "I do want to see how layouts behave when there are multiple posts to handle. Nothing to see here yet!", 1083 + "createdAt": "2025-07-12T19:57:03.240Z", 1084 + "visibility": "public" 1085 + }, 1086 + { 1087 + "$type": "com.whtwnd.blog.entry", 1088 + "theme": "github-light", 1089 + "title": "An ongoing experiment", 1090 + "content": "I want to own my website. I want it's content to live on the ATProtocol. Claude is giving me a hand with the implementation. Currently the stack uses Astro. I need demo content on WhiteWind to test it's ability to display my blog posts. This is the aforementioned demo content.", 1091 + "createdAt": "2025-07-11T21:04:33.022Z", 1092 + "visibility": "public" 1093 + } 1094 + ], 1095 + "generatedTypes": "export interface ComWhtwndBlogEntry {\n $type: 'com.whtwnd.blog.entry';\n theme?: string;\n title?: string;\n content?: string;\n createdAt?: string;\n visibility?: string;\n}\n", 1096 + "$types": [ 1097 + "com.whtwnd.blog.entry" 1098 + ] 1099 + }, 1100 + { 1101 + "name": "community.lexicon.calendar.rsvp", 1102 + "description": "community.lexicon.calendar.rsvp records", 1103 + "service": "unknown", 1104 + "sampleRecords": [ 1105 + { 1106 + "$type": "community.lexicon.calendar.rsvp", 1107 + "status": "community.lexicon.calendar.rsvp#interested", 1108 + "subject": { 1109 + "cid": "bafyreic6ev7ulowb7il4egk7kr5vwfgw5nweyj5dhkzlkid5sf3aqsvfji", 1110 + "uri": "at://did:plc:lehcqqkwzcwvjvw66uthu5oq/community.lexicon.calendar.event/3ltl5aficno2m" 1111 + }, 1112 + "createdAt": "2025-07-15T17:51:44.366Z" 1113 + }, 1114 + { 1115 + "$type": "community.lexicon.calendar.rsvp", 1116 + "status": "community.lexicon.calendar.rsvp#going", 1117 + "subject": { 1118 + "cid": "bafyreiapk47atkjb326wafy4z55ty4hdezmjmr57vf7korqfq7h2bcbhki", 1119 + "uri": "at://did:plc:stznz7qsokto2345qtdzogjb/community.lexicon.calendar.event/3lu3t4qnkqv2s" 1120 + }, 1121 + "createdAt": "2025-08-04T16:09:00.435Z" 1122 + } 1123 + ], 1124 + "generatedTypes": "export interface CommunityLexiconCalendarRsvp {\n $type: 'community.lexicon.calendar.rsvp';\n status?: string;\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 1125 + "$types": [ 1126 + "community.lexicon.calendar.rsvp" 1127 + ] 1128 + }, 1129 + { 1130 + "name": "events.smokesignal.app.profile", 1131 + "description": "events.smokesignal.app.profile records", 1132 + "service": "unknown", 1133 + "sampleRecords": [ 1134 + { 1135 + "tz": "America/New_York", 1136 + "$type": "events.smokesignal.app.profile" 1137 + } 1138 + ], 1139 + "generatedTypes": "export interface EventsSmokesignalAppProfile {\n $type: 'events.smokesignal.app.profile';\n tz?: string;\n}\n", 1140 + "$types": [ 1141 + "events.smokesignal.app.profile" 1142 + ] 1143 + }, 1144 + { 1145 + "name": "events.smokesignal.calendar.event", 1146 + "description": "events.smokesignal.calendar.event records", 1147 + "service": "unknown", 1148 + "sampleRecords": [ 1149 + { 1150 + "mode": "events.smokesignal.calendar.event#inperson", 1151 + "name": "IDSA Boston Open Studio Series - Motiv", 1152 + "text": "This May we're cohosting a series of open studios across Boston. Join us for a peek into your favorite studios and lots of socializing!\r\n\r\nJoin us for the second studio tour in our May Studio Series! Motiv is generously opening their doors to our community for a special peek into their studio, their work, and their culture.\r\n\r\nAfter the studio tour we'll head down the street to The Broadway (726 E Broadway, Boston) for food and friendship (BYO$).\r\n\r\nThis event is free to the public and open to both IDSA Members and Non-Members.", 1153 + "$type": "events.smokesignal.calendar.event", 1154 + "endsAt": "2025-05-16T00:00:00.000Z", 1155 + "status": "events.smokesignal.calendar.event#scheduled", 1156 + "location": { 1157 + "name": "Motiv Design", 1158 + "$type": "events.smokesignal.calendar.location#place", 1159 + "region": "MA", 1160 + "street": "803 Summer Street, #2nd floor", 1161 + "country": "US", 1162 + "locality": "Boston", 1163 + "postalCode": "02127" 1164 + }, 1165 + "startsAt": "2025-05-15T22:00:00.000Z", 1166 + "createdAt": "2025-05-13T20:11:16.764Z" 1167 + } 1168 + ], 1169 + "generatedTypes": "export interface EventsSmokesignalCalendarEvent {\n $type: 'events.smokesignal.calendar.event';\n mode?: string;\n name?: string;\n text?: string;\n endsAt?: string;\n status?: string;\n location?: Record<string, any>;\n startsAt?: string;\n createdAt?: string;\n}\n", 1170 + "$types": [ 1171 + "events.smokesignal.calendar.event" 1172 + ] 1173 + }, 1174 + { 1175 + "name": "farm.smol.games.skyrdle.score", 1176 + "description": "farm.smol.games.skyrdle.score records", 1177 + "service": "unknown", 1178 + "sampleRecords": [ 1179 + { 1180 + "hash": "8a77a61dcb3a7f8f41314d2ab411bdfccf1643f2ea3ce43315ae07b5d20b9210", 1181 + "$type": "farm.smol.games.skyrdle.score", 1182 + "isWin": true, 1183 + "score": 5, 1184 + "guesses": [ 1185 + { 1186 + "letters": [ 1187 + "P", 1188 + "O", 1189 + "I", 1190 + "N", 1191 + "T" 1192 + ], 1193 + "evaluation": [ 1194 + "present", 1195 + "present", 1196 + "absent", 1197 + "absent", 1198 + "absent" 1199 + ] 1200 + }, 1201 + { 1202 + "letters": [ 1203 + "S", 1204 + "L", 1205 + "O", 1206 + "P", 1207 + "E" 1208 + ], 1209 + "evaluation": [ 1210 + "absent", 1211 + "absent", 1212 + "present", 1213 + "present", 1214 + "absent" 1215 + ] 1216 + }, 1217 + { 1218 + "letters": [ 1219 + "C", 1220 + "R", 1221 + "O", 1222 + "G", 1223 + "A" 1224 + ], 1225 + "evaluation": [ 1226 + "absent", 1227 + "present", 1228 + "present", 1229 + "absent", 1230 + "present" 1231 + ] 1232 + }, 1233 + { 1234 + "letters": [ 1235 + "R", 1236 + "A", 1237 + "P", 1238 + "O", 1239 + "R" 1240 + ], 1241 + "evaluation": [ 1242 + "absent", 1243 + "correct", 1244 + "correct", 1245 + "correct", 1246 + "correct" 1247 + ] 1248 + }, 1249 + { 1250 + "letters": [ 1251 + "V", 1252 + "A", 1253 + "P", 1254 + "O", 1255 + "R" 1256 + ], 1257 + "evaluation": [ 1258 + "correct", 1259 + "correct", 1260 + "correct", 1261 + "correct", 1262 + "correct" 1263 + ] 1264 + } 1265 + ], 1266 + "timestamp": "2025-06-21T17:50:53.767Z", 1267 + "gameNumber": 9 1268 + }, 1269 + { 1270 + "hash": "2c3312d4eee2bb032d59b67676588cab5b72035c47f1696ab42845b6c0a36fa2", 1271 + "$type": "farm.smol.games.skyrdle.score", 1272 + "isWin": true, 1273 + "score": 5, 1274 + "guesses": [ 1275 + { 1276 + "letters": [ 1277 + "P", 1278 + "R", 1279 + "I", 1280 + "C", 1281 + "K" 1282 + ], 1283 + "evaluation": [ 1284 + "absent", 1285 + "correct", 1286 + "absent", 1287 + "absent", 1288 + "absent" 1289 + ] 1290 + }, 1291 + { 1292 + "letters": [ 1293 + "D", 1294 + "R", 1295 + "O", 1296 + "N", 1297 + "E" 1298 + ], 1299 + "evaluation": [ 1300 + "absent", 1301 + "correct", 1302 + "correct", 1303 + "absent", 1304 + "absent" 1305 + ] 1306 + }, 1307 + { 1308 + "letters": [ 1309 + "F", 1310 + "R", 1311 + "O", 1312 + "G", 1313 + "S" 1314 + ], 1315 + "evaluation": [ 1316 + "correct", 1317 + "correct", 1318 + "correct", 1319 + "absent", 1320 + "present" 1321 + ] 1322 + }, 1323 + { 1324 + "letters": [ 1325 + "F", 1326 + "F", 1327 + "O", 1328 + "S", 1329 + "T" 1330 + ], 1331 + "evaluation": [ 1332 + "correct", 1333 + "absent", 1334 + "correct", 1335 + "correct", 1336 + "correct" 1337 + ] 1338 + }, 1339 + { 1340 + "letters": [ 1341 + "F", 1342 + "R", 1343 + "O", 1344 + "S", 1345 + "T" 1346 + ], 1347 + "evaluation": [ 1348 + "correct", 1349 + "correct", 1350 + "correct", 1351 + "correct", 1352 + "correct" 1353 + ] 1354 + } 1355 + ], 1356 + "timestamp": "2025-06-20T13:33:55.182Z", 1357 + "gameNumber": 8 1358 + }, 1359 + { 1360 + "hash": "6e536c0ae04b57f9afe595541cf41844672d5eae9e59de1d707784748feba5a1", 1361 + "$type": "farm.smol.games.skyrdle.score", 1362 + "isWin": true, 1363 + "score": 3, 1364 + "guesses": [ 1365 + { 1366 + "letters": [ 1367 + "P", 1368 + "R", 1369 + "I", 1370 + "M", 1371 + "E" 1372 + ], 1373 + "evaluation": [ 1374 + "absent", 1375 + "absent", 1376 + "present", 1377 + "absent", 1378 + "absent" 1379 + ] 1380 + }, 1381 + { 1382 + "letters": [ 1383 + "S", 1384 + "C", 1385 + "H", 1386 + "I", 1387 + "T" 1388 + ], 1389 + "evaluation": [ 1390 + "correct", 1391 + "present", 1392 + "absent", 1393 + "correct", 1394 + "present" 1395 + ] 1396 + }, 1397 + { 1398 + "letters": [ 1399 + "S", 1400 + "T", 1401 + "O", 1402 + "I", 1403 + "C" 1404 + ], 1405 + "evaluation": [ 1406 + "correct", 1407 + "correct", 1408 + "correct", 1409 + "correct", 1410 + "correct" 1411 + ] 1412 + } 1413 + ], 1414 + "timestamp": "2025-06-19T14:30:27.746Z", 1415 + "gameNumber": 7 1416 + } 1417 + ], 1418 + "generatedTypes": "export interface FarmSmolGamesSkyrdleScore {\n $type: 'farm.smol.games.skyrdle.score';\n hash?: string;\n isWin?: boolean;\n score?: number;\n guesses?: Record<string, any>[];\n timestamp?: string;\n gameNumber?: number;\n}\n", 1419 + "$types": [ 1420 + "farm.smol.games.skyrdle.score" 1421 + ] 1422 + }, 1423 + { 1424 + "name": "fyi.bluelinks.links", 1425 + "description": "fyi.bluelinks.links records", 1426 + "service": "unknown", 1427 + "sampleRecords": [ 1428 + { 1429 + "$type": "fyi.bluelinks.links", 1430 + "links": [ 1431 + { 1432 + "id": "4fc54af2-46ad-4f89-aa94-a14bbfd60afb", 1433 + "url": "https://tynanpurdy.com", 1434 + "name": "Portfolio", 1435 + "$type": "fyi.bluelinks.links#link", 1436 + "order": 1, 1437 + "createdAt": "2025-06-16T20:23:31.823Z", 1438 + "description": "My past work" 1439 + }, 1440 + { 1441 + "id": "8d864819-c69c-43da-8d7b-b9635e36f67f", 1442 + "url": "https://blog.tynanpurdy.com", 1443 + "name": "Blog", 1444 + "$type": "fyi.bluelinks.links#link", 1445 + "order": 2, 1446 + "createdAt": "2025-06-16T20:24:07.424Z", 1447 + "description": "My writing" 1448 + } 1449 + ] 1450 + } 1451 + ], 1452 + "generatedTypes": "export interface FyiBluelinksLinks {\n $type: 'fyi.bluelinks.links';\n links?: Record<string, any>[];\n}\n", 1453 + "$types": [ 1454 + "fyi.bluelinks.links" 1455 + ] 1456 + }, 1457 + { 1458 + "name": "fyi.unravel.frontpage.comment", 1459 + "description": "fyi.unravel.frontpage.comment records", 1460 + "service": "unknown", 1461 + "sampleRecords": [ 1462 + { 1463 + "post": { 1464 + "cid": "bafyreicxjsuwe7thbqcu3qh5biliuxyou26nbmac6hhxv74u2jeuexx334", 1465 + "uri": "at://did:plc:vro3sykit2gjemuza2pwvxwy/fyi.unravel.frontpage.post/3lvbcvpm3js2c" 1466 + }, 1467 + "$type": "fyi.unravel.frontpage.comment", 1468 + "content": "I can confirm my vibecoded app has like 3 approaches to the same thing and its a mess to untangle. Esp for a noob who doesn't really know what is the right way to converge on", 1469 + "createdAt": "2025-08-02T01:06:19.685Z" 1470 + }, 1471 + { 1472 + "post": { 1473 + "cid": "bafyreibiy36sr55cyjd7d6kn7yuuadxk242cm5yqsyw7strdpdbhesoxga", 1474 + "uri": "at://did:plc:ofrbh253gwicbkc5nktqepol/fyi.unravel.frontpage.post/3luxczcviqk2h" 1475 + }, 1476 + "$type": "fyi.unravel.frontpage.comment", 1477 + "parent": { 1478 + "cid": "bafyreicoezztd45k677sfqtuevgg3zqi5dhdheevry5qcb2cydrfp72uuq", 1479 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/fyi.unravel.frontpage.comment/3lv2echl6xk2e" 1480 + }, 1481 + "content": "https://tangled.sh/@tynanpurdy.com/at-home", 1482 + "createdAt": "2025-07-28T19:48:17.572Z" 1483 + }, 1484 + { 1485 + "post": { 1486 + "cid": "bafyreibiy36sr55cyjd7d6kn7yuuadxk242cm5yqsyw7strdpdbhesoxga", 1487 + "uri": "at://did:plc:ofrbh253gwicbkc5nktqepol/fyi.unravel.frontpage.post/3luxczcviqk2h" 1488 + }, 1489 + "$type": "fyi.unravel.frontpage.comment", 1490 + "content": "My version of this has a slightly different stack but largely the same idea. I'd love to collab on what additional lexicon might be useful for the personal website category. Portfolio projects are still a struggle to me.", 1491 + "createdAt": "2025-07-28T19:47:46.719Z" 1492 + } 1493 + ], 1494 + "generatedTypes": "export interface FyiUnravelFrontpageComment {\n $type: 'fyi.unravel.frontpage.comment';\n post?: Record<string, any>;\n content?: string;\n createdAt?: string;\n}\n", 1495 + "$types": [ 1496 + "fyi.unravel.frontpage.comment" 1497 + ] 1498 + }, 1499 + { 1500 + "name": "fyi.unravel.frontpage.post", 1501 + "description": "fyi.unravel.frontpage.post records", 1502 + "service": "unknown", 1503 + "sampleRecords": [ 1504 + { 1505 + "url": "https://brittanyellich.com/bluesky-comments-likes/", 1506 + "$type": "fyi.unravel.frontpage.post", 1507 + "title": "I finally added Bluesky comments and likes to my blog (and you can too!)", 1508 + "createdAt": "2025-08-06T13:38:34.417Z" 1509 + }, 1510 + { 1511 + "url": "https://www.citationneeded.news/curate-with-rss/", 1512 + "$type": "fyi.unravel.frontpage.post", 1513 + "title": "Curate your own newspaper with RSS", 1514 + "createdAt": "2025-07-31T17:18:08.281Z" 1515 + }, 1516 + { 1517 + "url": "https://baileytownsend.dev/articles/host-a-pds-with-a-cloudflare-tunnel", 1518 + "$type": "fyi.unravel.frontpage.post", 1519 + "title": "Host a PDS via a Cloudflare Tunnel", 1520 + "createdAt": "2025-07-29T13:47:49.739Z" 1521 + } 1522 + ], 1523 + "generatedTypes": "export interface FyiUnravelFrontpagePost {\n $type: 'fyi.unravel.frontpage.post';\n url?: string;\n title?: string;\n createdAt?: string;\n}\n", 1524 + "$types": [ 1525 + "fyi.unravel.frontpage.post" 1526 + ] 1527 + }, 1528 + { 1529 + "name": "fyi.unravel.frontpage.vote", 1530 + "description": "fyi.unravel.frontpage.vote records", 1531 + "service": "unknown", 1532 + "sampleRecords": [ 1533 + { 1534 + "$type": "fyi.unravel.frontpage.vote", 1535 + "subject": { 1536 + "cid": "bafyreicxjsuwe7thbqcu3qh5biliuxyou26nbmac6hhxv74u2jeuexx334", 1537 + "uri": "at://did:plc:vro3sykit2gjemuza2pwvxwy/fyi.unravel.frontpage.post/3lvbcvpm3js2c" 1538 + }, 1539 + "createdAt": "2025-08-06T15:01:18.710Z" 1540 + }, 1541 + { 1542 + "$type": "fyi.unravel.frontpage.vote", 1543 + "subject": { 1544 + "cid": "bafyreihrgrqftgwr6u37p3tkoa23bh6z7vecc44hivvtichrar4rnl55ti", 1545 + "uri": "at://did:plc:mdjhvva6vlrswsj26cftjttd/fyi.unravel.frontpage.post/3lvbqj5kpm22v" 1546 + }, 1547 + "createdAt": "2025-08-06T14:29:08.506Z" 1548 + }, 1549 + { 1550 + "$type": "fyi.unravel.frontpage.vote", 1551 + "subject": { 1552 + "cid": "bafyreicbdf5lbimyqo6d2hgj2z7y3v2ligwqlczjzp2fmgl2s7vci5yqj4", 1553 + "uri": "at://did:plc:mdjhvva6vlrswsj26cftjttd/fyi.unravel.frontpage.post/3lu6mwifrmk2x" 1554 + }, 1555 + "createdAt": "2025-08-06T14:06:38.568Z" 1556 + } 1557 + ], 1558 + "generatedTypes": "export interface FyiUnravelFrontpageVote {\n $type: 'fyi.unravel.frontpage.vote';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 1559 + "$types": [ 1560 + "fyi.unravel.frontpage.vote" 1561 + ] 1562 + }, 1563 + { 1564 + "name": "im.flushing.right.now", 1565 + "description": "im.flushing.right.now records", 1566 + "service": "unknown", 1567 + "sampleRecords": [ 1568 + { 1569 + "text": "is flushing", 1570 + "$type": "im.flushing.right.now", 1571 + "emoji": "๐Ÿ’ฉ", 1572 + "createdAt": "2025-06-17T16:53:24-04:00" 1573 + } 1574 + ], 1575 + "generatedTypes": "export interface ImFlushingRightNow {\n $type: 'im.flushing.right.now';\n text?: string;\n emoji?: string;\n createdAt?: string;\n}\n", 1576 + "$types": [ 1577 + "im.flushing.right.now" 1578 + ] 1579 + }, 1580 + { 1581 + "name": "link.woosh.linkPage", 1582 + "description": "link.woosh.linkPage records", 1583 + "service": "unknown", 1584 + "sampleRecords": [ 1585 + { 1586 + "$type": "link.woosh.linkPage", 1587 + "collections": [ 1588 + { 1589 + "label": "Socials", 1590 + "links": [ 1591 + { 1592 + "uri": "https://bsky.app/profile/tynanpurdy.com", 1593 + "title": "Bluesky" 1594 + }, 1595 + { 1596 + "uri": "https://github.com/tynanpurdy", 1597 + "title": "GitHub" 1598 + }, 1599 + { 1600 + "uri": "https://www.linkedin.com/in/tynanpurdy", 1601 + "title": "LinkedIn" 1602 + } 1603 + ] 1604 + } 1605 + ] 1606 + } 1607 + ], 1608 + "generatedTypes": "export interface LinkWooshLinkPage {\n $type: 'link.woosh.linkPage';\n collections?: Record<string, any>[];\n}\n", 1609 + "$types": [ 1610 + "link.woosh.linkPage" 1611 + ] 1612 + }, 1613 + { 1614 + "name": "my.skylights.rel", 1615 + "description": "my.skylights.rel records", 1616 + "service": "unknown", 1617 + "sampleRecords": [ 1618 + { 1619 + "item": { 1620 + "ref": "tmdb:m", 1621 + "value": "861" 1622 + }, 1623 + "note": { 1624 + "value": "Great LOL moments. Fantastic special effects, especially in the makeup and prosthetics dept. ", 1625 + "createdAt": "2025-05-21T21:24:37.174Z", 1626 + "updatedAt": "2025-05-21T21:24:37.174Z" 1627 + }, 1628 + "$type": "my.skylights.rel", 1629 + "rating": { 1630 + "value": 8, 1631 + "createdAt": "2025-05-21T21:23:21.661Z" 1632 + } 1633 + } 1634 + ], 1635 + "generatedTypes": "export interface MySkylightsRel {\n $type: 'my.skylights.rel';\n item?: Record<string, any>;\n note?: Record<string, any>;\n rating?: Record<string, any>;\n}\n", 1636 + "$types": [ 1637 + "my.skylights.rel" 1638 + ] 1639 + }, 1640 + { 1641 + "name": "org.owdproject.application.windows", 1642 + "description": "org.owdproject.application.windows records", 1643 + "service": "unknown", 1644 + "sampleRecords": [ 1645 + { 1646 + "$type": "org.owdproject.application.windows", 1647 + "windows": {} 1648 + }, 1649 + { 1650 + "$type": "org.owdproject.application.windows", 1651 + "windows": {} 1652 + } 1653 + ], 1654 + "generatedTypes": "export interface OrgOwdprojectApplicationWindows {\n $type: 'org.owdproject.application.windows';\n}\n", 1655 + "$types": [ 1656 + "org.owdproject.application.windows" 1657 + ] 1658 + }, 1659 + { 1660 + "name": "org.owdproject.desktop", 1661 + "description": "org.owdproject.desktop records", 1662 + "service": "unknown", 1663 + "sampleRecords": [ 1664 + { 1665 + "$type": "org.owdproject.desktop", 1666 + "state": { 1667 + "volume": { 1668 + "master": 100 1669 + }, 1670 + "window": { 1671 + "positionZ": 2 1672 + }, 1673 + "workspace": { 1674 + "list": [ 1675 + "cNOD12iO", 1676 + "MjCjyI3o" 1677 + ], 1678 + "active": "cNOD12iO", 1679 + "overview": false 1680 + } 1681 + } 1682 + } 1683 + ], 1684 + "generatedTypes": "export interface OrgOwdprojectDesktop {\n $type: 'org.owdproject.desktop';\n state?: Record<string, any>;\n}\n", 1685 + "$types": [ 1686 + "org.owdproject.desktop" 1687 + ] 1688 + }, 1689 + { 1690 + "name": "org.scrapboard.list", 1691 + "description": "org.scrapboard.list records", 1692 + "service": "unknown", 1693 + "sampleRecords": [ 1694 + { 1695 + "name": "", 1696 + "$type": "org.scrapboard.list", 1697 + "createdAt": "2025-08-04T14:48:50.207Z", 1698 + "description": "" 1699 + } 1700 + ], 1701 + "generatedTypes": "export interface OrgScrapboardList {\n $type: 'org.scrapboard.list';\n name?: string;\n createdAt?: string;\n description?: string;\n}\n", 1702 + "$types": [ 1703 + "org.scrapboard.list" 1704 + ] 1705 + }, 1706 + { 1707 + "name": "org.scrapboard.listitem", 1708 + "description": "org.scrapboard.listitem records", 1709 + "service": "unknown", 1710 + "sampleRecords": [ 1711 + { 1712 + "url": "at://did:plc:gerrk3zpej5oloffu5cqtnly/app.bsky.feed.post/3lvj6fteywk2u?image=0", 1713 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/org.scrapboard.list/3lvlguetmjw2i", 1714 + "$type": "org.scrapboard.listitem", 1715 + "createdAt": "2025-08-04T14:48:58.704Z" 1716 + } 1717 + ], 1718 + "generatedTypes": "export interface OrgScrapboardListitem {\n $type: 'org.scrapboard.listitem';\n url?: string;\n list?: string;\n createdAt?: string;\n}\n", 1719 + "$types": [ 1720 + "org.scrapboard.listitem" 1721 + ] 1722 + }, 1723 + { 1724 + "name": "place.stream.chat.message", 1725 + "description": "place.stream.chat.message records", 1726 + "service": "unknown", 1727 + "sampleRecords": [ 1728 + { 1729 + "text": "thanks yall!", 1730 + "$type": "place.stream.chat.message", 1731 + "streamer": "did:plc:stznz7qsokto2345qtdzogjb", 1732 + "createdAt": "2025-08-04T17:59:34.556Z" 1733 + }, 1734 + { 1735 + "text": "thanks yall!", 1736 + "$type": "place.stream.chat.message", 1737 + "streamer": "did:plc:stznz7qsokto2345qtdzogjb", 1738 + "createdAt": "2025-08-04T17:59:34.555Z" 1739 + }, 1740 + { 1741 + "text": "spark, skyswipe, et al", 1742 + "$type": "place.stream.chat.message", 1743 + "streamer": "did:plc:stznz7qsokto2345qtdzogjb", 1744 + "createdAt": "2025-08-04T17:52:24.424Z" 1745 + } 1746 + ], 1747 + "generatedTypes": "export interface PlaceStreamChatMessage {\n $type: 'place.stream.chat.message';\n text?: string;\n streamer?: string;\n createdAt?: string;\n}\n", 1748 + "$types": [ 1749 + "place.stream.chat.message" 1750 + ] 1751 + }, 1752 + { 1753 + "name": "place.stream.chat.profile", 1754 + "description": "place.stream.chat.profile records", 1755 + "service": "unknown", 1756 + "sampleRecords": [ 1757 + { 1758 + "$type": "place.stream.chat.profile", 1759 + "color": { 1760 + "red": 76, 1761 + "blue": 118, 1762 + "green": 175 1763 + } 1764 + } 1765 + ], 1766 + "generatedTypes": "export interface PlaceStreamChatProfile {\n $type: 'place.stream.chat.profile';\n color?: Record<string, any>;\n}\n", 1767 + "$types": [ 1768 + "place.stream.chat.profile" 1769 + ] 1770 + }, 1771 + { 1772 + "name": "pub.leaflet.document", 1773 + "description": "pub.leaflet.document records", 1774 + "service": "unknown", 1775 + "sampleRecords": [ 1776 + { 1777 + "$type": "pub.leaflet.document", 1778 + "pages": [ 1779 + { 1780 + "$type": "pub.leaflet.pages.linearDocument", 1781 + "blocks": [ 1782 + { 1783 + "$type": "pub.leaflet.pages.linearDocument#block", 1784 + "block": { 1785 + "$type": "pub.leaflet.blocks.text", 1786 + "facets": [ 1787 + { 1788 + "index": { 1789 + "byteEnd": 521, 1790 + "byteStart": 508 1791 + }, 1792 + "features": [ 1793 + { 1794 + "$type": "pub.leaflet.richtext.facet#italic" 1795 + } 1796 + ] 1797 + } 1798 + ], 1799 + "plaintext": "I love all this community-led development of the open social web. Tech infra architecture and business models can break down the oppressive and monopolistic internet giants we have grown up with. This blog post is published over the AT protocol, an open standard for all kinds of social experiences. The data, followers, and interactions are stored on a Personal Data Server. I control the data. I can move to a new PDS if I wish. I can even host my PDS myself. My account hosted on my PDS is my account for any and every ATproto app in the world. " 1800 + } 1801 + }, 1802 + { 1803 + "$type": "pub.leaflet.pages.linearDocument#block", 1804 + "block": { 1805 + "$type": "pub.leaflet.blocks.text", 1806 + "facets": [], 1807 + "plaintext": "You can read this post on the leaflet website, which I have connected to the domain I own. You can also read it on Bluesky in a custom feed. You can also use any other client you want that can read an ATproto feed. There are dozens of clients for browsing and interacting with ATproto posts. You don't have to use Bluesky or it's infrastructure if you wish not to." 1808 + } 1809 + }, 1810 + { 1811 + "$type": "pub.leaflet.pages.linearDocument#block", 1812 + "block": { 1813 + "$type": "pub.leaflet.blocks.text", 1814 + "facets": [], 1815 + "plaintext": "The hype around Bluesky is great to see. It is also somewhat shortsighted to the broader vision of the future of social. It's the protocol that matters. The things it enables. It can be hard to realize how restricted the big platforms are until you see an alternative. The projects any indie dev can spin up in no time with ATproto are incredible. I encourage you to check some of them out. " 1816 + } 1817 + }, 1818 + { 1819 + "$type": "pub.leaflet.pages.linearDocument#block", 1820 + "block": { 1821 + "$type": "pub.leaflet.blocks.text", 1822 + "facets": [], 1823 + "plaintext": "" 1824 + } 1825 + }, 1826 + { 1827 + "$type": "pub.leaflet.pages.linearDocument#block", 1828 + "block": { 1829 + "$type": "pub.leaflet.blocks.text", 1830 + "facets": [ 1831 + { 1832 + "index": { 1833 + "byteEnd": 66, 1834 + "byteStart": 0 1835 + }, 1836 + "features": [ 1837 + { 1838 + "$type": "pub.leaflet.richtext.facet#italic" 1839 + } 1840 + ] 1841 + } 1842 + ], 1843 + "plaintext": "I know I did not address the subtitle, saving for a future post ;)" 1844 + } 1845 + } 1846 + ] 1847 + } 1848 + ], 1849 + "title": "Blogging directly on ATproto", 1850 + "author": "did:plc:6ayddqghxhciedbaofoxkcbs", 1851 + "postRef": { 1852 + "cid": "bafyreihwmgzema3jpptki4jod3skf4bmsjjikqgbkgkz7qreggjbiwwkpa", 1853 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3lrquu5rnsk2w", 1854 + "commit": { 1855 + "cid": "bafyreib2y34ku7v5gzfrjcgibg3qoitwgwbxtgo2azl2mnw3xz35bzh2lq", 1856 + "rev": "3lrquu5ured2l" 1857 + }, 1858 + "validationStatus": "valid" 1859 + }, 1860 + "description": "Which is somehow different from micro.blog...", 1861 + "publication": "at://did:plc:6ayddqghxhciedbaofoxkcbs/pub.leaflet.publication/3lptvotm3ms2o", 1862 + "publishedAt": "2025-06-16T21:01:42.095Z" 1863 + } 1864 + ], 1865 + "generatedTypes": "export interface PubLeafletDocument {\n $type: 'pub.leaflet.document';\n pages?: Record<string, any>[];\n title?: string;\n author?: string;\n postRef?: Record<string, any>;\n description?: string;\n publication?: string;\n publishedAt?: string;\n}\n", 1866 + "$types": [ 1867 + "pub.leaflet.document" 1868 + ] 1869 + }, 1870 + { 1871 + "name": "pub.leaflet.graph.subscription", 1872 + "description": "pub.leaflet.graph.subscription records", 1873 + "service": "unknown", 1874 + "sampleRecords": [ 1875 + { 1876 + "$type": "pub.leaflet.graph.subscription", 1877 + "publication": "at://did:plc:2cxgdrgtsmrbqnjkwyplmp43/pub.leaflet.publication/3lpqbbzc7x224" 1878 + }, 1879 + { 1880 + "$type": "pub.leaflet.graph.subscription", 1881 + "publication": "at://did:plc:u2grpouz5553mrn4x772pyfa/pub.leaflet.publication/3lve2jmb7c22j" 1882 + }, 1883 + { 1884 + "$type": "pub.leaflet.graph.subscription", 1885 + "publication": "at://did:plc:e3tv2pzlnuppocnc3wirsvl4/pub.leaflet.publication/3lrgwj6ytis2k" 1886 + } 1887 + ], 1888 + "generatedTypes": "export interface PubLeafletGraphSubscription {\n $type: 'pub.leaflet.graph.subscription';\n publication?: string;\n}\n", 1889 + "$types": [ 1890 + "pub.leaflet.graph.subscription" 1891 + ] 1892 + }, 1893 + { 1894 + "name": "pub.leaflet.publication", 1895 + "description": "pub.leaflet.publication records", 1896 + "service": "unknown", 1897 + "sampleRecords": [ 1898 + { 1899 + "icon": { 1900 + "$type": "blob", 1901 + "ref": { 1902 + "$link": "bafkreihz6y3xxbl5xasgrsfykyh6zizc63uv276lzengbvslikkqgndabe" 1903 + }, 1904 + "mimeType": "image/png", 1905 + "size": 75542 1906 + }, 1907 + "name": "Tynan's Leaflets", 1908 + "$type": "pub.leaflet.publication", 1909 + "base_path": "leaflets.tynanpurdy.com", 1910 + "description": "I'll play around with any ATproto gizmo" 1911 + } 1912 + ], 1913 + "generatedTypes": "export interface PubLeafletPublication {\n $type: 'pub.leaflet.publication';\n icon?: Record<string, any>;\n name?: string;\n base_path?: string;\n description?: string;\n}\n", 1914 + "$types": [ 1915 + "pub.leaflet.publication" 1916 + ] 1917 + }, 1918 + { 1919 + "name": "sh.tangled.actor.profile", 1920 + "description": "sh.tangled.actor.profile records", 1921 + "service": "sh.tangled", 1922 + "sampleRecords": [ 1923 + { 1924 + "$type": "sh.tangled.actor.profile", 1925 + "links": [ 1926 + "https://tynanpurdy.com", 1927 + "https://blog.tynanpurdy.com", 1928 + "", 1929 + "", 1930 + "" 1931 + ], 1932 + "stats": [ 1933 + "", 1934 + "" 1935 + ], 1936 + "bluesky": true, 1937 + "location": "", 1938 + "description": "", 1939 + "pinnedRepositories": [ 1940 + "", 1941 + "", 1942 + "", 1943 + "", 1944 + "", 1945 + "" 1946 + ] 1947 + } 1948 + ], 1949 + "generatedTypes": "export interface ShTangledActorProfile {\n $type: 'sh.tangled.actor.profile';\n links?: string[];\n stats?: string[];\n bluesky?: boolean;\n location?: string;\n description?: string;\n pinnedRepositories?: string[];\n}\n", 1950 + "$types": [ 1951 + "sh.tangled.actor.profile" 1952 + ] 1953 + }, 1954 + { 1955 + "name": "sh.tangled.feed.star", 1956 + "description": "sh.tangled.feed.star records", 1957 + "service": "sh.tangled", 1958 + "sampleRecords": [ 1959 + { 1960 + "$type": "sh.tangled.feed.star", 1961 + "subject": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/sh.tangled.repo/3lnkvfhpcz422", 1962 + "createdAt": "2025-07-31T01:08:31Z" 1963 + } 1964 + ], 1965 + "generatedTypes": "export interface ShTangledFeedStar {\n $type: 'sh.tangled.feed.star';\n subject?: string;\n createdAt?: string;\n}\n", 1966 + "$types": [ 1967 + "sh.tangled.feed.star" 1968 + ] 1969 + }, 1970 + { 1971 + "name": "sh.tangled.publicKey", 1972 + "description": "sh.tangled.publicKey records", 1973 + "service": "sh.tangled", 1974 + "sampleRecords": [ 1975 + { 1976 + "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0U2oHBrZAOYUO0klCU7HpwgGEAJprdrI3Nk8H0YzOo", 1977 + "name": "ray", 1978 + "$type": "sh.tangled.publicKey", 1979 + "createdAt": "2025-07-07T23:52:11Z" 1980 + } 1981 + ], 1982 + "generatedTypes": "export interface ShTangledPublicKey {\n $type: 'sh.tangled.publicKey';\n key?: string;\n name?: string;\n createdAt?: string;\n}\n", 1983 + "$types": [ 1984 + "sh.tangled.publicKey" 1985 + ] 1986 + }, 1987 + { 1988 + "name": "sh.tangled.repo", 1989 + "description": "sh.tangled.repo records", 1990 + "service": "sh.tangled", 1991 + "sampleRecords": [ 1992 + { 1993 + "knot": "knot1.tangled.sh", 1994 + "name": "at-home", 1995 + "$type": "sh.tangled.repo", 1996 + "owner": "did:plc:6ayddqghxhciedbaofoxkcbs", 1997 + "createdAt": "2025-07-11T22:07:14Z" 1998 + }, 1999 + { 2000 + "knot": "knot1.tangled.sh", 2001 + "name": "atprofile", 2002 + "$type": "sh.tangled.repo", 2003 + "owner": "did:plc:6ayddqghxhciedbaofoxkcbs", 2004 + "createdAt": "2025-07-07T23:27:33Z" 2005 + } 2006 + ], 2007 + "generatedTypes": "export interface ShTangledRepo {\n $type: 'sh.tangled.repo';\n knot?: string;\n name?: string;\n owner?: string;\n createdAt?: string;\n}\n", 2008 + "$types": [ 2009 + "sh.tangled.repo" 2010 + ] 2011 + }, 2012 + { 2013 + "name": "so.sprk.actor.profile", 2014 + "description": "so.sprk.actor.profile records", 2015 + "service": "unknown", 2016 + "sampleRecords": [ 2017 + { 2018 + "$type": "so.sprk.actor.profile", 2019 + "avatar": { 2020 + "$type": "blob", 2021 + "ref": { 2022 + "$link": "bafkreig6momh2fkdfhhqwkcjsw4vycubptufe6aeolsddtgg6felh4bvoe" 2023 + }, 2024 + "mimeType": "image/jpeg", 2025 + "size": 915425 2026 + }, 2027 + "description": "he/him\nExperience Designer | Tech nerd | Curious Creative\n๐Ÿ“ Boston ๐Ÿ‡บ๐Ÿ‡ธ\n๐Ÿ“– acotar, a different kind of power\n\nmy work: tynanpurdy.com\nmy writing: blog.tynanpurdy.com", 2028 + "displayName": "Tynan Purdy" 2029 + } 2030 + ], 2031 + "generatedTypes": "export interface SoSprkActorProfile {\n $type: 'so.sprk.actor.profile';\n avatar?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n", 2032 + "$types": [ 2033 + "so.sprk.actor.profile" 2034 + ] 2035 + }, 2036 + { 2037 + "name": "so.sprk.feed.like", 2038 + "description": "so.sprk.feed.like records", 2039 + "service": "unknown", 2040 + "sampleRecords": [ 2041 + { 2042 + "$type": "so.sprk.feed.like", 2043 + "subject": { 2044 + "cid": "bafyreibezk3pahyxmgt32xf6bdox6ycpx4uxeetzj2ol5xtat6nyf3uw5i", 2045 + "uri": "at://did:plc:owhabhwzxfp2zxh6nxszkzmg/app.bsky.feed.post/3lv7kobh6js2w" 2046 + }, 2047 + "createdAt": "2025-07-30T21:26:26.637994Z" 2048 + } 2049 + ], 2050 + "generatedTypes": "export interface SoSprkFeedLike {\n $type: 'so.sprk.feed.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 2051 + "$types": [ 2052 + "so.sprk.feed.like" 2053 + ] 2054 + }, 2055 + { 2056 + "name": "so.sprk.feed.story", 2057 + "description": "so.sprk.feed.story records", 2058 + "service": "unknown", 2059 + "sampleRecords": [ 2060 + { 2061 + "tags": [], 2062 + "$type": "so.sprk.feed.story", 2063 + "media": { 2064 + "$type": "so.sprk.embed.images", 2065 + "images": [ 2066 + { 2067 + "alt": "Schnauzer dog on couch. Fresh hair cut and wearing a green bowtie.", 2068 + "image": { 2069 + "$type": "blob", 2070 + "ref": { 2071 + "$link": "bafkreifdfejeiitg46ec2wijg7mjmna4hyne2c5m45435jm4yiiw3nids4" 2072 + }, 2073 + "mimeType": "image/jpeg", 2074 + "size": 2651327 2075 + } 2076 + } 2077 + ] 2078 + }, 2079 + "createdAt": "2025-07-08T17:35:01.830824", 2080 + "selfLabels": [] 2081 + } 2082 + ], 2083 + "generatedTypes": "export interface SoSprkFeedStory {\n $type: 'so.sprk.feed.story';\n tags?: any[];\n media?: Record<string, any>;\n createdAt?: string;\n selfLabels?: any[];\n}\n", 2084 + "$types": [ 2085 + "so.sprk.feed.story" 2086 + ] 2087 + }, 2088 + { 2089 + "name": "social.grain.actor.profile", 2090 + "description": "social.grain.actor.profile records", 2091 + "service": "grain.social", 2092 + "sampleRecords": [ 2093 + { 2094 + "$type": "social.grain.actor.profile", 2095 + "avatar": { 2096 + "$type": "blob", 2097 + "ref": { 2098 + "$link": "bafkreias2logev3efvxo6kvplme2s6j2whvmhxpwpnsaq4mo52kqy7ktay" 2099 + }, 2100 + "mimeType": "image/jpeg", 2101 + "size": 992154 2102 + }, 2103 + "description": "he/him\r\nExperience Designer | Tech nerd | Curious Creative\r\n๐Ÿ“ Boston ๐Ÿ‡บ๐Ÿ‡ธ\r\n\r\nmy work: tynanpurdy.com\r\nmy writing: blog.tynanpurdy.com", 2104 + "displayName": "Tynan Purdy" 2105 + } 2106 + ], 2107 + "generatedTypes": "export interface SocialGrainActorProfile {\n $type: 'social.grain.actor.profile';\n avatar?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n", 2108 + "$types": [ 2109 + "social.grain.actor.profile" 2110 + ] 2111 + }, 2112 + { 2113 + "name": "social.grain.favorite", 2114 + "description": "social.grain.favorite records", 2115 + "service": "grain.social", 2116 + "sampleRecords": [ 2117 + { 2118 + "$type": "social.grain.favorite", 2119 + "subject": "at://did:plc:njgakmquzxdmz6t32j27hgee/social.grain.gallery/3lrpq72tqo22d", 2120 + "createdAt": "2025-06-17T21:19:47.133Z" 2121 + } 2122 + ], 2123 + "generatedTypes": "export interface SocialGrainFavorite {\n $type: 'social.grain.favorite';\n subject?: string;\n createdAt?: string;\n}\n", 2124 + "$types": [ 2125 + "social.grain.favorite" 2126 + ] 2127 + }, 2128 + { 2129 + "name": "social.grain.gallery", 2130 + "description": "Grain.social image galleries", 2131 + "service": "grain.social", 2132 + "sampleRecords": [ 2133 + { 2134 + "$type": "social.grain.gallery", 2135 + "title": "Zuko the dog", 2136 + "createdAt": "2025-07-28T14:43:35.815Z", 2137 + "updatedAt": "2025-07-28T14:43:35.815Z", 2138 + "description": "" 2139 + }, 2140 + { 2141 + "$type": "social.grain.gallery", 2142 + "title": "Opensauce 2025", 2143 + "createdAt": "2025-07-23T22:18:41.069Z", 2144 + "updatedAt": "2025-07-23T22:18:41.069Z", 2145 + "description": "I truly do not understand Bay Area summer. Why is it cold!?" 2146 + }, 2147 + { 2148 + "$type": "social.grain.gallery", 2149 + "title": "Engagement shoot", 2150 + "createdAt": "2025-06-17T21:45:24.826Z", 2151 + "description": "The date is set! Stay tuned. \n๐Ÿ“ Harvard Arboretum\n๐Ÿ“ท Karina Bhattacharya" 2152 + } 2153 + ], 2154 + "generatedTypes": "export interface SocialGrainGallery {\n $type: 'social.grain.gallery';\n title?: string;\n createdAt?: string;\n updatedAt?: string;\n description?: string;\n}\n", 2155 + "$types": [ 2156 + "social.grain.gallery" 2157 + ] 2158 + }, 2159 + { 2160 + "name": "social.grain.gallery.item", 2161 + "description": "social.grain.gallery.item records", 2162 + "service": "grain.social", 2163 + "sampleRecords": [ 2164 + { 2165 + "item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h", 2166 + "$type": "social.grain.gallery.item", 2167 + "gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luztckj2us2h", 2168 + "position": 0, 2169 + "createdAt": "2025-07-28T14:43:45.462Z" 2170 + }, 2171 + { 2172 + "item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2mrz2v22s", 2173 + "$type": "social.grain.gallery.item", 2174 + "gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luo2fpjjyc2s", 2175 + "position": 13, 2176 + "createdAt": "2025-07-23T22:22:39.555Z" 2177 + }, 2178 + { 2179 + "item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2miqkm22s", 2180 + "$type": "social.grain.gallery.item", 2181 + "gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luo2fpjjyc2s", 2182 + "position": 12, 2183 + "createdAt": "2025-07-23T22:22:29.750Z" 2184 + } 2185 + ], 2186 + "generatedTypes": "export interface SocialGrainGalleryItem {\n $type: 'social.grain.gallery.item';\n item?: string;\n gallery?: string;\n position?: number;\n createdAt?: string;\n}\n", 2187 + "$types": [ 2188 + "social.grain.gallery.item" 2189 + ] 2190 + }, 2191 + { 2192 + "name": "social.grain.graph.follow", 2193 + "description": "social.grain.graph.follow records", 2194 + "service": "grain.social", 2195 + "sampleRecords": [ 2196 + { 2197 + "$type": "social.grain.graph.follow", 2198 + "subject": "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 2199 + "createdAt": "2025-07-25T20:28:17.255Z" 2200 + }, 2201 + { 2202 + "$type": "social.grain.graph.follow", 2203 + "subject": "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 2204 + "createdAt": "2025-07-25T20:28:16.699Z" 2205 + } 2206 + ], 2207 + "generatedTypes": "export interface SocialGrainGraphFollow {\n $type: 'social.grain.graph.follow';\n subject?: string;\n createdAt?: string;\n}\n", 2208 + "$types": [ 2209 + "social.grain.graph.follow" 2210 + ] 2211 + }, 2212 + { 2213 + "name": "social.grain.photo", 2214 + "description": "social.grain.photo records", 2215 + "service": "grain.social", 2216 + "sampleRecords": [ 2217 + { 2218 + "alt": "Grey miniature schnauzer plopped on a wood fenced porch overlooking trees and a morning sky", 2219 + "cid": "bafyreibigenfwakuv3rohdzoi4rsypqtjuvuguyxbzepvpltbc6srvumeu", 2220 + "did": "did:plc:6ayddqghxhciedbaofoxkcbs", 2221 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h", 2222 + "$type": "social.grain.photo", 2223 + "photo": { 2224 + "$type": "blob", 2225 + "ref": { 2226 + "$link": "bafkreibnepqn6itqpukdr776mm4qi2afbmzw7gwbs6i56pogvlnzr7tt5q" 2227 + }, 2228 + "mimeType": "image/jpeg", 2229 + "size": 965086 2230 + }, 2231 + "createdAt": "2025-07-28T14:43:44.523Z", 2232 + "indexedAt": "2025-07-28T14:43:45.223Z", 2233 + "aspectRatio": { 2234 + "width": 2000, 2235 + "height": 2667 2236 + } 2237 + }, 2238 + { 2239 + "alt": "", 2240 + "$type": "social.grain.photo", 2241 + "photo": { 2242 + "$type": "blob", 2243 + "ref": { 2244 + "$link": "bafkreiangeamxp4rlmc66fheiosnk2odfpw4h5dzynfv7nhj6sy2jcyxzu" 2245 + }, 2246 + "mimeType": "image/jpeg", 2247 + "size": 981537 2248 + }, 2249 + "createdAt": "2025-07-23T22:22:38.556Z", 2250 + "aspectRatio": { 2251 + "width": 2000, 2252 + "height": 2667 2253 + } 2254 + }, 2255 + { 2256 + "alt": "", 2257 + "$type": "social.grain.photo", 2258 + "photo": { 2259 + "$type": "blob", 2260 + "ref": { 2261 + "$link": "bafkreiarwsd5ksvgzt5ydexepqfdq54sthj2mzone3d5hgou66qv333yi4" 2262 + }, 2263 + "mimeType": "image/jpeg", 2264 + "size": 963879 2265 + }, 2266 + "createdAt": "2025-07-23T22:22:28.840Z", 2267 + "aspectRatio": { 2268 + "width": 2000, 2269 + "height": 2667 2270 + } 2271 + } 2272 + ], 2273 + "generatedTypes": "export interface SocialGrainPhoto {\n $type: 'social.grain.photo';\n alt?: string;\n cid?: string;\n did?: string;\n uri?: string;\n photo?: Record<string, any>;\n createdAt?: string;\n indexedAt?: string;\n aspectRatio?: Record<string, any>;\n}\n", 2274 + "$types": [ 2275 + "social.grain.photo" 2276 + ] 2277 + }, 2278 + { 2279 + "name": "social.grain.photo.exif", 2280 + "description": "social.grain.photo.exif records", 2281 + "service": "grain.social", 2282 + "sampleRecords": [ 2283 + { 2284 + "iSO": 25000000, 2285 + "make": "Apple", 2286 + "$type": "social.grain.photo.exif", 2287 + "flash": "Flash did not fire, compulsory flash mode", 2288 + "model": "iPhone 14 Pro", 2289 + "photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h", 2290 + "fNumber": 2800000, 2291 + "lensMake": "Apple", 2292 + "createdAt": "2025-07-28T14:43:45.239Z", 2293 + "lensModel": "iPhone 14 Pro back camera 9mm f/2.8", 2294 + "exposureTime": 3378, 2295 + "dateTimeOriginal": "2025-07-28T09:50:08", 2296 + "focalLengthIn35mmFormat": 77000000 2297 + }, 2298 + { 2299 + "iSO": 80000000, 2300 + "make": "Apple", 2301 + "$type": "social.grain.photo.exif", 2302 + "flash": "Flash did not fire, compulsory flash mode", 2303 + "model": "iPhone 14 Pro", 2304 + "photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2mrz2v22s", 2305 + "fNumber": 1780000, 2306 + "lensMake": "Apple", 2307 + "createdAt": "2025-07-23T22:22:39.342Z", 2308 + "lensModel": "iPhone 14 Pro back triple camera 6.86mm f/1.78", 2309 + "exposureTime": 347, 2310 + "dateTimeOriginal": "2025-07-19T16:11:52", 2311 + "focalLengthIn35mmFormat": 24000000 2312 + }, 2313 + { 2314 + "iSO": 800000000, 2315 + "make": "Apple", 2316 + "$type": "social.grain.photo.exif", 2317 + "flash": "Flash did not fire, compulsory flash mode", 2318 + "model": "iPhone 14 Pro", 2319 + "photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2miqkm22s", 2320 + "fNumber": 1780000, 2321 + "lensMake": "Apple", 2322 + "createdAt": "2025-07-23T22:22:29.512Z", 2323 + "lensModel": "iPhone 14 Pro back triple camera 6.86mm f/1.78", 2324 + "exposureTime": 16667, 2325 + "dateTimeOriginal": "2025-07-17T20:15:51", 2326 + "focalLengthIn35mmFormat": 24000000 2327 + } 2328 + ], 2329 + "generatedTypes": "export interface SocialGrainPhotoExif {\n $type: 'social.grain.photo.exif';\n iSO?: number;\n make?: string;\n flash?: string;\n model?: string;\n photo?: string;\n fNumber?: number;\n lensMake?: string;\n createdAt?: string;\n lensModel?: string;\n exposureTime?: number;\n dateTimeOriginal?: string;\n focalLengthIn35mmFormat?: number;\n}\n", 2330 + "$types": [ 2331 + "social.grain.photo.exif" 2332 + ] 2333 + }, 2334 + { 2335 + "name": "social.pinksky.app.preference", 2336 + "description": "social.pinksky.app.preference records", 2337 + "service": "unknown", 2338 + "sampleRecords": [ 2339 + { 2340 + "slug": "onboarding", 2341 + "$type": "social.pinksky.app.preference", 2342 + "value": "completed", 2343 + "createdAt": "2025-05-21T21:27:01.789Z" 2344 + } 2345 + ], 2346 + "generatedTypes": "export interface SocialPinkskyAppPreference {\n $type: 'social.pinksky.app.preference';\n slug?: string;\n value?: string;\n createdAt?: string;\n}\n", 2347 + "$types": [ 2348 + "social.pinksky.app.preference" 2349 + ] 2350 + } 2351 + ], 2352 + "totalCollections": 64, 2353 + "totalRecords": 124, 2354 + "generatedAt": "2025-08-06T17:33:43.119Z", 2355 + "repository": { 2356 + "handle": "tynanpurdy.com", 2357 + "did": "did:plc:6ayddqghxhciedbaofoxkcbs", 2358 + "recordCount": 0 2359 + } 2360 + }
+759
src/lib/generated/discovered-types.ts
··· 1 + // Auto-generated types from collection discovery 2 + // Generated at: 2025-08-06T17:33:43.119Z 3 + // Repository: tynanpurdy.com (did:plc:6ayddqghxhciedbaofoxkcbs) 4 + // Collections: 64, Records: 124 5 + 6 + // Collection: app.bsky.actor.profile 7 + // Service: bsky.app 8 + // Types: app.bsky.actor.profile 9 + export interface AppBskyActorProfile { 10 + $type: 'app.bsky.actor.profile'; 11 + avatar?: Record<string, any>; 12 + banner?: Record<string, any>; 13 + description?: string; 14 + displayName?: string; 15 + } 16 + 17 + 18 + // Collection: app.bsky.feed.like 19 + // Service: bsky.app 20 + // Types: app.bsky.feed.like 21 + export interface AppBskyFeedLike { 22 + $type: 'app.bsky.feed.like'; 23 + subject?: Record<string, any>; 24 + createdAt?: string; 25 + } 26 + 27 + 28 + // Collection: app.bsky.feed.post 29 + // Service: bsky.app 30 + // Types: app.bsky.feed.post 31 + export interface AppBskyFeedPost { 32 + $type: 'app.bsky.feed.post'; 33 + text?: string; 34 + langs?: string[]; 35 + createdAt?: string; 36 + } 37 + 38 + 39 + // Collection: app.bsky.feed.postgate 40 + // Service: bsky.app 41 + // Types: app.bsky.feed.postgate 42 + export interface AppBskyFeedPostgate { 43 + $type: 'app.bsky.feed.postgate'; 44 + post?: string; 45 + createdAt?: string; 46 + embeddingRules?: Record<string, any>[]; 47 + detachedEmbeddingUris?: any[]; 48 + } 49 + 50 + 51 + // Collection: app.bsky.feed.repost 52 + // Service: bsky.app 53 + // Types: app.bsky.feed.repost 54 + export interface AppBskyFeedRepost { 55 + $type: 'app.bsky.feed.repost'; 56 + subject?: Record<string, any>; 57 + createdAt?: string; 58 + } 59 + 60 + 61 + // Collection: app.bsky.feed.threadgate 62 + // Service: bsky.app 63 + // Types: app.bsky.feed.threadgate 64 + export interface AppBskyFeedThreadgate { 65 + $type: 'app.bsky.feed.threadgate'; 66 + post?: string; 67 + allow?: Record<string, any>[]; 68 + createdAt?: string; 69 + hiddenReplies?: any[]; 70 + } 71 + 72 + 73 + // Collection: app.bsky.graph.block 74 + // Service: bsky.app 75 + // Types: app.bsky.graph.block 76 + export interface AppBskyGraphBlock { 77 + $type: 'app.bsky.graph.block'; 78 + subject?: string; 79 + createdAt?: string; 80 + } 81 + 82 + 83 + // Collection: app.bsky.graph.follow 84 + // Service: bsky.app 85 + // Types: app.bsky.graph.follow 86 + export interface AppBskyGraphFollow { 87 + $type: 'app.bsky.graph.follow'; 88 + subject?: string; 89 + createdAt?: string; 90 + } 91 + 92 + 93 + // Collection: app.bsky.graph.list 94 + // Service: bsky.app 95 + // Types: app.bsky.graph.list 96 + export interface AppBskyGraphList { 97 + $type: 'app.bsky.graph.list'; 98 + name?: string; 99 + purpose?: string; 100 + createdAt?: string; 101 + description?: string; 102 + } 103 + 104 + 105 + // Collection: app.bsky.graph.listitem 106 + // Service: bsky.app 107 + // Types: app.bsky.graph.listitem 108 + export interface AppBskyGraphListitem { 109 + $type: 'app.bsky.graph.listitem'; 110 + list?: string; 111 + subject?: string; 112 + createdAt?: string; 113 + } 114 + 115 + 116 + // Collection: app.bsky.graph.starterpack 117 + // Service: bsky.app 118 + // Types: app.bsky.graph.starterpack 119 + export interface AppBskyGraphStarterpack { 120 + $type: 'app.bsky.graph.starterpack'; 121 + list?: string; 122 + name?: string; 123 + feeds?: Record<string, any>[]; 124 + createdAt?: string; 125 + updatedAt?: string; 126 + } 127 + 128 + 129 + // Collection: app.bsky.graph.verification 130 + // Service: bsky.app 131 + // Types: app.bsky.graph.verification 132 + export interface AppBskyGraphVerification { 133 + $type: 'app.bsky.graph.verification'; 134 + handle?: string; 135 + subject?: string; 136 + createdAt?: string; 137 + displayName?: string; 138 + } 139 + 140 + 141 + // Collection: app.popsky.list 142 + // Service: unknown 143 + // Types: app.popsky.list 144 + export interface AppPopskyList { 145 + $type: 'app.popsky.list'; 146 + name?: string; 147 + authorDid?: string; 148 + createdAt?: string; 149 + indexedAt?: string; 150 + description?: string; 151 + } 152 + 153 + 154 + // Collection: app.popsky.listItem 155 + // Service: unknown 156 + // Types: app.popsky.listItem 157 + export interface AppPopskyListItem { 158 + $type: 'app.popsky.listItem'; 159 + addedAt?: string; 160 + listUri?: string; 161 + identifiers?: Record<string, any>; 162 + creativeWorkType?: string; 163 + } 164 + 165 + 166 + // Collection: app.popsky.profile 167 + // Service: unknown 168 + // Types: app.popsky.profile 169 + export interface AppPopskyProfile { 170 + $type: 'app.popsky.profile'; 171 + createdAt?: string; 172 + description?: string; 173 + displayName?: string; 174 + } 175 + 176 + 177 + // Collection: app.popsky.review 178 + // Service: unknown 179 + // Types: app.popsky.review 180 + export interface AppPopskyReview { 181 + $type: 'app.popsky.review'; 182 + tags?: any[]; 183 + facets?: any[]; 184 + rating?: number; 185 + createdAt?: string; 186 + isRevisit?: boolean; 187 + reviewText?: string; 188 + identifiers?: Record<string, any>; 189 + containsSpoilers?: boolean; 190 + creativeWorkType?: string; 191 + } 192 + 193 + 194 + // Collection: app.rocksky.album 195 + // Service: unknown 196 + // Types: app.rocksky.album 197 + export interface AppRockskyAlbum { 198 + $type: 'app.rocksky.album'; 199 + year?: number; 200 + title?: string; 201 + artist?: string; 202 + albumArt?: Record<string, any>; 203 + createdAt?: string; 204 + releaseDate?: string; 205 + } 206 + 207 + 208 + // Collection: app.rocksky.artist 209 + // Service: unknown 210 + // Types: app.rocksky.artist 211 + export interface AppRockskyArtist { 212 + $type: 'app.rocksky.artist'; 213 + name?: string; 214 + picture?: Record<string, any>; 215 + createdAt?: string; 216 + } 217 + 218 + 219 + // Collection: app.rocksky.like 220 + // Service: unknown 221 + // Types: app.rocksky.like 222 + export interface AppRockskyLike { 223 + $type: 'app.rocksky.like'; 224 + subject?: Record<string, any>; 225 + createdAt?: string; 226 + } 227 + 228 + 229 + // Collection: app.rocksky.scrobble 230 + // Service: unknown 231 + // Types: app.rocksky.scrobble 232 + export interface AppRockskyScrobble { 233 + $type: 'app.rocksky.scrobble'; 234 + year?: number; 235 + album?: string; 236 + title?: string; 237 + artist?: string; 238 + albumArt?: Record<string, any>; 239 + duration?: number; 240 + createdAt?: string; 241 + discNumber?: number; 242 + albumArtist?: string; 243 + releaseDate?: string; 244 + spotifyLink?: string; 245 + trackNumber?: number; 246 + } 247 + 248 + 249 + // Collection: app.rocksky.song 250 + // Service: unknown 251 + // Types: app.rocksky.song 252 + export interface AppRockskySong { 253 + $type: 'app.rocksky.song'; 254 + year?: number; 255 + album?: string; 256 + title?: string; 257 + artist?: string; 258 + albumArt?: Record<string, any>; 259 + duration?: number; 260 + createdAt?: string; 261 + discNumber?: number; 262 + albumArtist?: string; 263 + releaseDate?: string; 264 + spotifyLink?: string; 265 + trackNumber?: number; 266 + } 267 + 268 + 269 + // Collection: blue.flashes.actor.profile 270 + // Service: unknown 271 + // Types: blue.flashes.actor.profile 272 + export interface BlueFlashesActorProfile { 273 + $type: 'blue.flashes.actor.profile'; 274 + createdAt?: string; 275 + showFeeds?: boolean; 276 + showLikes?: boolean; 277 + showLists?: boolean; 278 + showMedia?: boolean; 279 + enablePortfolio?: boolean; 280 + portfolioLayout?: string; 281 + allowRawDownload?: boolean; 282 + } 283 + 284 + 285 + // Collection: blue.linkat.board 286 + // Service: unknown 287 + // Types: blue.linkat.board 288 + export interface BlueLinkatBoard { 289 + $type: 'blue.linkat.board'; 290 + cards?: Record<string, any>[]; 291 + } 292 + 293 + 294 + // Collection: buzz.bookhive.book 295 + // Service: unknown 296 + // Types: buzz.bookhive.book 297 + export interface BuzzBookhiveBook { 298 + $type: 'buzz.bookhive.book'; 299 + cover?: Record<string, any>; 300 + title?: string; 301 + hiveId?: string; 302 + status?: string; 303 + authors?: string; 304 + createdAt?: string; 305 + } 306 + 307 + 308 + // Collection: chat.bsky.actor.declaration 309 + // Service: unknown 310 + // Types: chat.bsky.actor.declaration 311 + export interface ChatBskyActorDeclaration { 312 + $type: 'chat.bsky.actor.declaration'; 313 + allowIncoming?: string; 314 + } 315 + 316 + 317 + // Collection: chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog 318 + // Service: unknown 319 + // Types: chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog 320 + export interface ChatRoomy01JPNX7AA9BSM6TY2GWW1TR5V7Catalog { 321 + $type: 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog'; 322 + id?: string; 323 + } 324 + 325 + 326 + // Collection: chat.roomy.profile 327 + // Service: unknown 328 + // Types: chat.roomy.profile 329 + export interface ChatRoomyProfile { 330 + $type: 'chat.roomy.profile'; 331 + accountId?: string; 332 + profileId?: string; 333 + } 334 + 335 + 336 + // Collection: com.germnetwork.keypackage 337 + // Service: unknown 338 + // Types: com.germnetwork.keypackage 339 + export interface ComGermnetworkKeypackage { 340 + $type: 'com.germnetwork.keypackage'; 341 + anchorHello?: string; 342 + } 343 + 344 + 345 + // Collection: com.whtwnd.blog.entry 346 + // Service: unknown 347 + // Types: com.whtwnd.blog.entry 348 + export interface ComWhtwndBlogEntry { 349 + $type: 'com.whtwnd.blog.entry'; 350 + theme?: string; 351 + title?: string; 352 + content?: string; 353 + createdAt?: string; 354 + visibility?: string; 355 + } 356 + 357 + 358 + // Collection: community.lexicon.calendar.rsvp 359 + // Service: unknown 360 + // Types: community.lexicon.calendar.rsvp 361 + export interface CommunityLexiconCalendarRsvp { 362 + $type: 'community.lexicon.calendar.rsvp'; 363 + status?: string; 364 + subject?: Record<string, any>; 365 + createdAt?: string; 366 + } 367 + 368 + 369 + // Collection: events.smokesignal.app.profile 370 + // Service: unknown 371 + // Types: events.smokesignal.app.profile 372 + export interface EventsSmokesignalAppProfile { 373 + $type: 'events.smokesignal.app.profile'; 374 + tz?: string; 375 + } 376 + 377 + 378 + // Collection: events.smokesignal.calendar.event 379 + // Service: unknown 380 + // Types: events.smokesignal.calendar.event 381 + export interface EventsSmokesignalCalendarEvent { 382 + $type: 'events.smokesignal.calendar.event'; 383 + mode?: string; 384 + name?: string; 385 + text?: string; 386 + endsAt?: string; 387 + status?: string; 388 + location?: Record<string, any>; 389 + startsAt?: string; 390 + createdAt?: string; 391 + } 392 + 393 + 394 + // Collection: farm.smol.games.skyrdle.score 395 + // Service: unknown 396 + // Types: farm.smol.games.skyrdle.score 397 + export interface FarmSmolGamesSkyrdleScore { 398 + $type: 'farm.smol.games.skyrdle.score'; 399 + hash?: string; 400 + isWin?: boolean; 401 + score?: number; 402 + guesses?: Record<string, any>[]; 403 + timestamp?: string; 404 + gameNumber?: number; 405 + } 406 + 407 + 408 + // Collection: fyi.bluelinks.links 409 + // Service: unknown 410 + // Types: fyi.bluelinks.links 411 + export interface FyiBluelinksLinks { 412 + $type: 'fyi.bluelinks.links'; 413 + links?: Record<string, any>[]; 414 + } 415 + 416 + 417 + // Collection: fyi.unravel.frontpage.comment 418 + // Service: unknown 419 + // Types: fyi.unravel.frontpage.comment 420 + export interface FyiUnravelFrontpageComment { 421 + $type: 'fyi.unravel.frontpage.comment'; 422 + post?: Record<string, any>; 423 + content?: string; 424 + createdAt?: string; 425 + } 426 + 427 + 428 + // Collection: fyi.unravel.frontpage.post 429 + // Service: unknown 430 + // Types: fyi.unravel.frontpage.post 431 + export interface FyiUnravelFrontpagePost { 432 + $type: 'fyi.unravel.frontpage.post'; 433 + url?: string; 434 + title?: string; 435 + createdAt?: string; 436 + } 437 + 438 + 439 + // Collection: fyi.unravel.frontpage.vote 440 + // Service: unknown 441 + // Types: fyi.unravel.frontpage.vote 442 + export interface FyiUnravelFrontpageVote { 443 + $type: 'fyi.unravel.frontpage.vote'; 444 + subject?: Record<string, any>; 445 + createdAt?: string; 446 + } 447 + 448 + 449 + // Collection: im.flushing.right.now 450 + // Service: unknown 451 + // Types: im.flushing.right.now 452 + export interface ImFlushingRightNow { 453 + $type: 'im.flushing.right.now'; 454 + text?: string; 455 + emoji?: string; 456 + createdAt?: string; 457 + } 458 + 459 + 460 + // Collection: link.woosh.linkPage 461 + // Service: unknown 462 + // Types: link.woosh.linkPage 463 + export interface LinkWooshLinkPage { 464 + $type: 'link.woosh.linkPage'; 465 + collections?: Record<string, any>[]; 466 + } 467 + 468 + 469 + // Collection: my.skylights.rel 470 + // Service: unknown 471 + // Types: my.skylights.rel 472 + export interface MySkylightsRel { 473 + $type: 'my.skylights.rel'; 474 + item?: Record<string, any>; 475 + note?: Record<string, any>; 476 + rating?: Record<string, any>; 477 + } 478 + 479 + 480 + // Collection: org.owdproject.application.windows 481 + // Service: unknown 482 + // Types: org.owdproject.application.windows 483 + export interface OrgOwdprojectApplicationWindows { 484 + $type: 'org.owdproject.application.windows'; 485 + } 486 + 487 + 488 + // Collection: org.owdproject.desktop 489 + // Service: unknown 490 + // Types: org.owdproject.desktop 491 + export interface OrgOwdprojectDesktop { 492 + $type: 'org.owdproject.desktop'; 493 + state?: Record<string, any>; 494 + } 495 + 496 + 497 + // Collection: org.scrapboard.list 498 + // Service: unknown 499 + // Types: org.scrapboard.list 500 + export interface OrgScrapboardList { 501 + $type: 'org.scrapboard.list'; 502 + name?: string; 503 + createdAt?: string; 504 + description?: string; 505 + } 506 + 507 + 508 + // Collection: org.scrapboard.listitem 509 + // Service: unknown 510 + // Types: org.scrapboard.listitem 511 + export interface OrgScrapboardListitem { 512 + $type: 'org.scrapboard.listitem'; 513 + url?: string; 514 + list?: string; 515 + createdAt?: string; 516 + } 517 + 518 + 519 + // Collection: place.stream.chat.message 520 + // Service: unknown 521 + // Types: place.stream.chat.message 522 + export interface PlaceStreamChatMessage { 523 + $type: 'place.stream.chat.message'; 524 + text?: string; 525 + streamer?: string; 526 + createdAt?: string; 527 + } 528 + 529 + 530 + // Collection: place.stream.chat.profile 531 + // Service: unknown 532 + // Types: place.stream.chat.profile 533 + export interface PlaceStreamChatProfile { 534 + $type: 'place.stream.chat.profile'; 535 + color?: Record<string, any>; 536 + } 537 + 538 + 539 + // Collection: pub.leaflet.document 540 + // Service: unknown 541 + // Types: pub.leaflet.document 542 + export interface PubLeafletDocument { 543 + $type: 'pub.leaflet.document'; 544 + pages?: Record<string, any>[]; 545 + title?: string; 546 + author?: string; 547 + postRef?: Record<string, any>; 548 + description?: string; 549 + publication?: string; 550 + publishedAt?: string; 551 + } 552 + 553 + 554 + // Collection: pub.leaflet.graph.subscription 555 + // Service: unknown 556 + // Types: pub.leaflet.graph.subscription 557 + export interface PubLeafletGraphSubscription { 558 + $type: 'pub.leaflet.graph.subscription'; 559 + publication?: string; 560 + } 561 + 562 + 563 + // Collection: pub.leaflet.publication 564 + // Service: unknown 565 + // Types: pub.leaflet.publication 566 + export interface PubLeafletPublication { 567 + $type: 'pub.leaflet.publication'; 568 + icon?: Record<string, any>; 569 + name?: string; 570 + base_path?: string; 571 + description?: string; 572 + } 573 + 574 + 575 + // Collection: sh.tangled.actor.profile 576 + // Service: sh.tangled 577 + // Types: sh.tangled.actor.profile 578 + export interface ShTangledActorProfile { 579 + $type: 'sh.tangled.actor.profile'; 580 + links?: string[]; 581 + stats?: string[]; 582 + bluesky?: boolean; 583 + location?: string; 584 + description?: string; 585 + pinnedRepositories?: string[]; 586 + } 587 + 588 + 589 + // Collection: sh.tangled.feed.star 590 + // Service: sh.tangled 591 + // Types: sh.tangled.feed.star 592 + export interface ShTangledFeedStar { 593 + $type: 'sh.tangled.feed.star'; 594 + subject?: string; 595 + createdAt?: string; 596 + } 597 + 598 + 599 + // Collection: sh.tangled.publicKey 600 + // Service: sh.tangled 601 + // Types: sh.tangled.publicKey 602 + export interface ShTangledPublicKey { 603 + $type: 'sh.tangled.publicKey'; 604 + key?: string; 605 + name?: string; 606 + createdAt?: string; 607 + } 608 + 609 + 610 + // Collection: sh.tangled.repo 611 + // Service: sh.tangled 612 + // Types: sh.tangled.repo 613 + export interface ShTangledRepo { 614 + $type: 'sh.tangled.repo'; 615 + knot?: string; 616 + name?: string; 617 + owner?: string; 618 + createdAt?: string; 619 + } 620 + 621 + 622 + // Collection: so.sprk.actor.profile 623 + // Service: unknown 624 + // Types: so.sprk.actor.profile 625 + export interface SoSprkActorProfile { 626 + $type: 'so.sprk.actor.profile'; 627 + avatar?: Record<string, any>; 628 + description?: string; 629 + displayName?: string; 630 + } 631 + 632 + 633 + // Collection: so.sprk.feed.like 634 + // Service: unknown 635 + // Types: so.sprk.feed.like 636 + export interface SoSprkFeedLike { 637 + $type: 'so.sprk.feed.like'; 638 + subject?: Record<string, any>; 639 + createdAt?: string; 640 + } 641 + 642 + 643 + // Collection: so.sprk.feed.story 644 + // Service: unknown 645 + // Types: so.sprk.feed.story 646 + export interface SoSprkFeedStory { 647 + $type: 'so.sprk.feed.story'; 648 + tags?: any[]; 649 + media?: Record<string, any>; 650 + createdAt?: string; 651 + selfLabels?: any[]; 652 + } 653 + 654 + 655 + // Collection: social.grain.actor.profile 656 + // Service: grain.social 657 + // Types: social.grain.actor.profile 658 + export interface SocialGrainActorProfile { 659 + $type: 'social.grain.actor.profile'; 660 + avatar?: Record<string, any>; 661 + description?: string; 662 + displayName?: string; 663 + } 664 + 665 + 666 + // Collection: social.grain.favorite 667 + // Service: grain.social 668 + // Types: social.grain.favorite 669 + export interface SocialGrainFavorite { 670 + $type: 'social.grain.favorite'; 671 + subject?: string; 672 + createdAt?: string; 673 + } 674 + 675 + 676 + // Collection: social.grain.gallery 677 + // Service: grain.social 678 + // Types: social.grain.gallery 679 + export interface SocialGrainGallery { 680 + $type: 'social.grain.gallery'; 681 + title?: string; 682 + createdAt?: string; 683 + updatedAt?: string; 684 + description?: string; 685 + } 686 + 687 + 688 + // Collection: social.grain.gallery.item 689 + // Service: grain.social 690 + // Types: social.grain.gallery.item 691 + export interface SocialGrainGalleryItem { 692 + $type: 'social.grain.gallery.item'; 693 + item?: string; 694 + gallery?: string; 695 + position?: number; 696 + createdAt?: string; 697 + } 698 + 699 + 700 + // Collection: social.grain.graph.follow 701 + // Service: grain.social 702 + // Types: social.grain.graph.follow 703 + export interface SocialGrainGraphFollow { 704 + $type: 'social.grain.graph.follow'; 705 + subject?: string; 706 + createdAt?: string; 707 + } 708 + 709 + 710 + // Collection: social.grain.photo 711 + // Service: grain.social 712 + // Types: social.grain.photo 713 + export interface SocialGrainPhoto { 714 + $type: 'social.grain.photo'; 715 + alt?: string; 716 + cid?: string; 717 + did?: string; 718 + uri?: string; 719 + photo?: Record<string, any>; 720 + createdAt?: string; 721 + indexedAt?: string; 722 + aspectRatio?: Record<string, any>; 723 + } 724 + 725 + 726 + // Collection: social.grain.photo.exif 727 + // Service: grain.social 728 + // Types: social.grain.photo.exif 729 + export interface SocialGrainPhotoExif { 730 + $type: 'social.grain.photo.exif'; 731 + iSO?: number; 732 + make?: string; 733 + flash?: string; 734 + model?: string; 735 + photo?: string; 736 + fNumber?: number; 737 + lensMake?: string; 738 + createdAt?: string; 739 + lensModel?: string; 740 + exposureTime?: number; 741 + dateTimeOriginal?: string; 742 + focalLengthIn35mmFormat?: number; 743 + } 744 + 745 + 746 + // Collection: social.pinksky.app.preference 747 + // Service: unknown 748 + // Types: social.pinksky.app.preference 749 + export interface SocialPinkskyAppPreference { 750 + $type: 'social.pinksky.app.preference'; 751 + slug?: string; 752 + value?: string; 753 + createdAt?: string; 754 + } 755 + 756 + 757 + // Union type for all discovered types 758 + export type DiscoveredTypes = 'app.bsky.actor.profile' | 'app.bsky.feed.like' | 'app.bsky.feed.post' | 'app.bsky.feed.postgate' | 'app.bsky.feed.repost' | 'app.bsky.feed.threadgate' | 'app.bsky.graph.block' | 'app.bsky.graph.follow' | 'app.bsky.graph.list' | 'app.bsky.graph.listitem' | 'app.bsky.graph.starterpack' | 'app.bsky.graph.verification' | 'app.popsky.list' | 'app.popsky.listItem' | 'app.popsky.profile' | 'app.popsky.review' | 'app.rocksky.album' | 'app.rocksky.artist' | 'app.rocksky.like' | 'app.rocksky.scrobble' | 'app.rocksky.song' | 'blue.flashes.actor.profile' | 'blue.linkat.board' | 'buzz.bookhive.book' | 'chat.bsky.actor.declaration' | 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog' | 'chat.roomy.profile' | 'com.germnetwork.keypackage' | 'com.whtwnd.blog.entry' | 'community.lexicon.calendar.rsvp' | 'events.smokesignal.app.profile' | 'events.smokesignal.calendar.event' | 'farm.smol.games.skyrdle.score' | 'fyi.bluelinks.links' | 'fyi.unravel.frontpage.comment' | 'fyi.unravel.frontpage.post' | 'fyi.unravel.frontpage.vote' | 'im.flushing.right.now' | 'link.woosh.linkPage' | 'my.skylights.rel' | 'org.owdproject.application.windows' | 'org.owdproject.desktop' | 'org.scrapboard.list' | 'org.scrapboard.listitem' | 'place.stream.chat.message' | 'place.stream.chat.profile' | 'pub.leaflet.document' | 'pub.leaflet.graph.subscription' | 'pub.leaflet.publication' | 'sh.tangled.actor.profile' | 'sh.tangled.feed.star' | 'sh.tangled.publicKey' | 'sh.tangled.repo' | 'so.sprk.actor.profile' | 'so.sprk.feed.like' | 'so.sprk.feed.story' | 'social.grain.actor.profile' | 'social.grain.favorite' | 'social.grain.gallery' | 'social.grain.gallery.item' | 'social.grain.graph.follow' | 'social.grain.photo' | 'social.grain.photo.exif' | 'social.pinksky.app.preference'; 759 +
+22
src/lib/generated/lexicon-types.ts
··· 1 + // Generated index of all lexicon types 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + import type { AStatusUpdate } from './a-status-update'; 5 + import type { ComWhtwndBlogEntry } from './com-whtwnd-blog-entry'; 6 + import type { SocialGrainGalleryItem } from './social-grain-gallery-item'; 7 + import type { SocialGrainGallery } from './social-grain-gallery'; 8 + import type { SocialGrainPhotoExif } from './social-grain-photo-exif'; 9 + import type { SocialGrainPhoto } from './social-grain-photo'; 10 + 11 + // Union type for all generated lexicon records 12 + export type GeneratedLexiconUnion = AStatusUpdate | ComWhtwndBlogEntry | SocialGrainGalleryItem | SocialGrainGallery | SocialGrainPhotoExif | SocialGrainPhoto; 13 + 14 + // Type map for component registry 15 + export type GeneratedLexiconTypeMap = { 16 + 'AStatusUpdate': AStatusUpdate; 17 + 'ComWhtwndBlogEntry': ComWhtwndBlogEntry; 18 + 'SocialGrainGalleryItem': SocialGrainGalleryItem; 19 + 'SocialGrainGallery': SocialGrainGallery; 20 + 'SocialGrainPhotoExif': SocialGrainPhotoExif; 21 + 'SocialGrainPhoto': SocialGrainPhoto; 22 + };
+19
src/lib/generated/social-grain-gallery.ts
··· 1 + // Generated from lexicon schema: social.grain.gallery 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainGalleryRecord { 5 + title: string; 6 + description?: string; 7 + facets?: any[]; 8 + labels?: any; 9 + updatedAt?: string; 10 + createdAt: string; 11 + } 12 + 13 + export interface SocialGrainGallery { 14 + $type: 'social.grain.gallery'; 15 + value: SocialGrainGalleryRecord; 16 + } 17 + 18 + // Helper type for discriminated unions 19 + export type SocialGrainGalleryUnion = SocialGrainGallery;
+25
src/lib/generated/social-grain-photo-exif.ts
··· 1 + // Generated from lexicon schema: social.grain.photo.exif 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainPhotoExifRecord { 5 + photo: string; 6 + createdAt: string; 7 + dateTimeOriginal?: string; 8 + exposureTime?: number; 9 + fNumber?: number; 10 + flash?: string; 11 + focalLengthIn35mmFormat?: number; 12 + iSO?: number; 13 + lensMake?: string; 14 + lensModel?: string; 15 + make?: string; 16 + model?: string; 17 + } 18 + 19 + export interface SocialGrainPhotoExif { 20 + $type: 'social.grain.photo.exif'; 21 + value: SocialGrainPhotoExifRecord; 22 + } 23 + 24 + // Helper type for discriminated unions 25 + export type SocialGrainPhotoExifUnion = SocialGrainPhotoExif;
+17
src/lib/generated/social-grain-photo.ts
··· 1 + // Generated from lexicon schema: social.grain.photo 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainPhotoRecord { 5 + photo: any; 6 + alt?: string; 7 + aspectRatio: any; 8 + createdAt: string; 9 + } 10 + 11 + export interface SocialGrainPhoto { 12 + $type: 'social.grain.photo'; 13 + value: SocialGrainPhotoRecord; 14 + } 15 + 16 + // Helper type for discriminated unions 17 + export type SocialGrainPhotoUnion = SocialGrainPhoto;
+2 -2
src/lib/services/content-renderer.ts
··· 1 1 import { AtprotoBrowser } from '../atproto/atproto-browser'; 2 2 import { loadConfig } from '../config/site'; 3 - import type { AtprotoRecord } from '../types/atproto'; 3 + import type { AtprotoRecord } from '../atproto/atproto-browser'; 4 4 5 5 export interface ContentRendererOptions { 6 6 showAuthor?: boolean; ··· 39 39 'app.bsky.feed.post': 'BlueskyPost', 40 40 'app.bsky.actor.profile#whitewindBlogPost': 'WhitewindBlogPost', 41 41 'app.bsky.actor.profile#leafletPublication': 'LeafletPublication', 42 - 'app.bsky.actor.profile#grainImageGallery': 'GrainImageGallery', 42 + 43 43 'gallery.display': 'GalleryDisplay', 44 44 }; 45 45
+178
src/lib/services/content-service.ts
··· 1 + import { AtprotoBrowser } from '../atproto/atproto-browser'; 2 + import { loadConfig } from '../config/site'; 3 + 4 + export interface ContentRecord { 5 + uri: string; 6 + cid: string; 7 + value: any; 8 + indexedAt: string; 9 + collection: string; 10 + } 11 + 12 + export interface ProcessedContent { 13 + $type: string; 14 + collection: string; 15 + uri: string; 16 + data: any; 17 + createdAt: Date; 18 + } 19 + 20 + export class ContentService { 21 + private browser: AtprotoBrowser; 22 + private config: any; 23 + private cache: Map<string, ProcessedContent[]> = new Map(); 24 + 25 + constructor() { 26 + this.config = loadConfig(); 27 + this.browser = new AtprotoBrowser(); 28 + } 29 + 30 + async getContentFromCollection(identifier: string, collection: string): Promise<ProcessedContent[]> { 31 + const cacheKey = `${identifier}:${collection}`; 32 + 33 + if (this.cache.has(cacheKey)) { 34 + return this.cache.get(cacheKey)!; 35 + } 36 + 37 + try { 38 + console.log(`Fetching ${collection} for ${identifier}...`); 39 + const collectionInfo = await this.browser.getCollectionRecords(identifier, collection); 40 + console.log(`Collection info for ${collection}:`, collectionInfo); 41 + 42 + if (!collectionInfo || !collectionInfo.records) { 43 + console.log(`No records found for ${collection}`); 44 + return []; 45 + } 46 + 47 + console.log(`Found ${collectionInfo.records.length} records for ${collection}`); 48 + 49 + // Debug: Show first few records 50 + if (collectionInfo.records.length > 0) { 51 + console.log(`First record in ${collection}:`, JSON.stringify(collectionInfo.records[0], null, 2)); 52 + } 53 + 54 + const processed = collectionInfo.records.map(record => this.processRecord(record)); 55 + 56 + this.cache.set(cacheKey, processed); 57 + return processed; 58 + } catch (error) { 59 + console.error(`Error fetching ${collection}:`, error); 60 + return []; 61 + } 62 + } 63 + 64 + private processRecord(record: ContentRecord): ProcessedContent { 65 + return { 66 + $type: record.value.$type || 'unknown', 67 + collection: record.collection, 68 + uri: record.uri, 69 + data: record.value, 70 + createdAt: new Date(record.value.createdAt || record.indexedAt) 71 + }; 72 + } 73 + 74 + // Get galleries specifically 75 + async getGalleries(identifier: string): Promise<ProcessedContent[]> { 76 + return this.getContentFromCollection(identifier, 'social.grain.gallery'); 77 + } 78 + 79 + // Get gallery items specifically with linked photos 80 + async getGalleryItems(identifier: string): Promise<ProcessedContent[]> { 81 + console.log(`Fetching gallery items for ${identifier}...`); 82 + const galleryItems = await this.getContentFromCollection(identifier, 'social.grain.gallery.item'); 83 + console.log(`Found ${galleryItems.length} gallery items`); 84 + 85 + if (galleryItems.length === 0) { 86 + console.log('No gallery items found - this might be the issue'); 87 + // Let's also try to fetch galleries to see if they exist 88 + const galleries = await this.getContentFromCollection(identifier, 'social.grain.gallery'); 89 + console.log(`Found ${galleries.length} galleries`); 90 + return []; 91 + } 92 + 93 + // For each gallery item, try to fetch the linked photo 94 + const enrichedItems = await Promise.all( 95 + galleryItems.map(async (item) => { 96 + console.log(`Processing gallery item: ${item.uri}`); 97 + console.log(`Item data:`, JSON.stringify(item.data, null, 2)); 98 + 99 + if (item.data.item && typeof item.data.item === 'string') { 100 + try { 101 + // Extract the photo URI from the item field 102 + const photoUri = item.data.item; 103 + console.log(`Fetching linked photo: ${photoUri}`); 104 + 105 + // Make sure we have a complete URI with record ID 106 + if (!photoUri.includes('/social.grain.photo/')) { 107 + console.log(`Invalid photo URI format: ${photoUri}`); 108 + return item; 109 + } 110 + 111 + const photoRecord = await this.browser.getRecord(photoUri); 112 + 113 + if (photoRecord && photoRecord.value) { 114 + console.log(`Found photo record:`, JSON.stringify(photoRecord.value, null, 2)); 115 + // Merge the photo data into the gallery item 116 + return { 117 + ...item, 118 + data: { 119 + ...item.data, 120 + linkedPhoto: photoRecord.value, 121 + photoUri: photoUri 122 + } 123 + }; 124 + } else { 125 + console.log(`No photo record found for: ${photoUri}`); 126 + console.log(`Photo record was:`, photoRecord); 127 + 128 + // Let's try to fetch the photo record directly to see what's happening 129 + try { 130 + console.log(`Attempting to fetch photo record directly...`); 131 + const directResponse = await this.browser.agent.api.com.atproto.repo.getRecord({ 132 + uri: photoUri 133 + }); 134 + console.log(`Direct API response:`, directResponse); 135 + } catch (directError) { 136 + console.log(`Direct API error:`, directError); 137 + } 138 + } 139 + } catch (error) { 140 + console.log(`Could not fetch linked photo for ${item.uri}:`, error); 141 + } 142 + } else { 143 + console.log(`No item field found in gallery item:`, item.data); 144 + } 145 + return item; 146 + }) 147 + ); 148 + 149 + console.log(`Returning ${enrichedItems.length} enriched gallery items`); 150 + return enrichedItems; 151 + } 152 + 153 + // Get posts 154 + async getPosts(identifier: string): Promise<ProcessedContent[]> { 155 + return this.getContentFromCollection(identifier, 'app.bsky.feed.post'); 156 + } 157 + 158 + // Get profile 159 + async getProfile(identifier: string): Promise<ProcessedContent[]> { 160 + return this.getContentFromCollection(identifier, 'app.bsky.actor.profile'); 161 + } 162 + 163 + // Get all content from multiple collections 164 + async getAllContent(identifier: string, collections: string[]): Promise<ProcessedContent[]> { 165 + const allContent: ProcessedContent[] = []; 166 + 167 + for (const collection of collections) { 168 + const content = await this.getContentFromCollection(identifier, collection); 169 + allContent.push(...content); 170 + } 171 + 172 + return allContent; 173 + } 174 + 175 + clearCache(): void { 176 + this.cache.clear(); 177 + } 178 + }
-271
src/lib/services/content-system.ts
··· 1 - import { AtprotoBrowser } from '../atproto/atproto-browser'; 2 - import { JetstreamClient } from '../atproto/jetstream-client'; 3 - import { GrainGalleryService } from './grain-gallery-service'; 4 - import { loadConfig } from '../config/site'; 5 - import type { AtprotoRecord } from '../types/atproto'; 6 - 7 - export interface ContentItem { 8 - uri: string; 9 - cid: string; 10 - $type: string; 11 - collection: string; 12 - createdAt: string; 13 - indexedAt: string; 14 - value: any; 15 - service: string; 16 - operation?: 'create' | 'update' | 'delete'; 17 - } 18 - 19 - export interface ContentFeed { 20 - items: ContentItem[]; 21 - lastUpdated: string; 22 - totalItems: number; 23 - collections: string[]; 24 - } 25 - 26 - export interface ContentSystemConfig { 27 - enableStreaming?: boolean; 28 - buildTimeOnly?: boolean; 29 - collections?: string[]; 30 - maxItems?: number; 31 - } 32 - 33 - export class ContentSystem { 34 - private browser: AtprotoBrowser; 35 - private jetstream: JetstreamClient; 36 - private grainGalleryService: GrainGalleryService; 37 - private config: any; 38 - private contentFeed: ContentFeed; 39 - private isStreaming = false; 40 - 41 - constructor() { 42 - this.config = loadConfig(); 43 - this.browser = new AtprotoBrowser(); 44 - this.jetstream = new JetstreamClient(); 45 - this.grainGalleryService = new GrainGalleryService(); 46 - 47 - this.contentFeed = { 48 - items: [], 49 - lastUpdated: new Date().toISOString(), 50 - totalItems: 0, 51 - collections: [] 52 - }; 53 - } 54 - 55 - // Initialize content system (build-time) 56 - async initialize(identifier: string, options: ContentSystemConfig = {}): Promise<ContentFeed> { 57 - console.log('๐Ÿš€ Initializing content system for:', identifier); 58 - 59 - try { 60 - // Get repository info 61 - const repoInfo = await this.browser.getRepoInfo(identifier); 62 - if (!repoInfo) { 63 - throw new Error(`Could not get repository info for: ${identifier}`); 64 - } 65 - 66 - console.log('๐Ÿ“Š Repository info:', { 67 - handle: repoInfo.handle, 68 - did: repoInfo.did, 69 - collections: repoInfo.collections.length, 70 - recordCount: repoInfo.recordCount 71 - }); 72 - 73 - // Gather all content from collections 74 - const allItems: ContentItem[] = []; 75 - const collections = options.collections || repoInfo.collections; 76 - 77 - for (const collection of collections) { 78 - console.log(`๐Ÿ“ฆ Fetching from collection: ${collection}`); 79 - const records = await this.browser.getCollectionRecords(identifier, collection, options.maxItems || 100); 80 - 81 - if (records && records.records) { 82 - for (const record of records.records) { 83 - const contentItem: ContentItem = { 84 - uri: record.uri, 85 - cid: record.cid, 86 - $type: record.$type, 87 - collection: record.collection, 88 - createdAt: record.value?.createdAt || record.indexedAt, 89 - indexedAt: record.indexedAt, 90 - value: record.value, 91 - service: this.inferService(record.$type, record.collection), 92 - operation: 'create' // Build-time items are existing 93 - }; 94 - 95 - allItems.push(contentItem); 96 - } 97 - } 98 - } 99 - 100 - // Sort by creation date (newest first) 101 - allItems.sort((a, b) => { 102 - const dateA = new Date(a.createdAt); 103 - const dateB = new Date(b.createdAt); 104 - return dateB.getTime() - dateA.getTime(); 105 - }); 106 - 107 - this.contentFeed = { 108 - items: allItems, 109 - lastUpdated: new Date().toISOString(), 110 - totalItems: allItems.length, 111 - collections: collections 112 - }; 113 - 114 - console.log(`โœ… Content system initialized with ${allItems.length} items`); 115 - 116 - // Start streaming if enabled 117 - if (!options.buildTimeOnly && options.enableStreaming !== false) { 118 - await this.startStreaming(identifier); 119 - } 120 - 121 - return this.contentFeed; 122 - } catch (error) { 123 - console.error('Error initializing content system:', error); 124 - throw error; 125 - } 126 - } 127 - 128 - // Start real-time streaming 129 - async startStreaming(identifier: string): Promise<void> { 130 - if (this.isStreaming) { 131 - console.log('โš ๏ธ Already streaming'); 132 - return; 133 - } 134 - 135 - console.log('๐ŸŒŠ Starting real-time content streaming...'); 136 - this.isStreaming = true; 137 - 138 - // Set up jetstream event handlers 139 - this.jetstream.onRecord((record) => { 140 - this.handleNewContent(record); 141 - }); 142 - 143 - this.jetstream.onError((error) => { 144 - console.error('โŒ Jetstream error:', error); 145 - }); 146 - 147 - this.jetstream.onConnect(() => { 148 - console.log('โœ… Connected to real-time stream'); 149 - }); 150 - 151 - this.jetstream.onDisconnect(() => { 152 - console.log('๐Ÿ”Œ Disconnected from real-time stream'); 153 - this.isStreaming = false; 154 - }); 155 - 156 - // Start streaming 157 - await this.jetstream.startStreaming(); 158 - } 159 - 160 - // Handle new content from streaming 161 - private handleNewContent(jetstreamRecord: any): void { 162 - const contentItem: ContentItem = { 163 - uri: jetstreamRecord.uri, 164 - cid: jetstreamRecord.cid, 165 - $type: jetstreamRecord.$type, 166 - collection: jetstreamRecord.collection, 167 - createdAt: jetstreamRecord.value?.createdAt || jetstreamRecord.indexedAt, 168 - indexedAt: jetstreamRecord.indexedAt, 169 - value: jetstreamRecord.value, 170 - service: jetstreamRecord.service, 171 - operation: jetstreamRecord.operation 172 - }; 173 - 174 - // Add to beginning of feed (newest first) 175 - this.contentFeed.items.unshift(contentItem); 176 - this.contentFeed.totalItems++; 177 - this.contentFeed.lastUpdated = new Date().toISOString(); 178 - 179 - console.log('๐Ÿ“ New content added:', { 180 - $type: contentItem.$type, 181 - collection: contentItem.collection, 182 - operation: contentItem.operation 183 - }); 184 - 185 - // Emit event for UI updates 186 - this.emitContentUpdate(contentItem); 187 - } 188 - 189 - // Get current content feed 190 - getContentFeed(): ContentFeed { 191 - return this.contentFeed; 192 - } 193 - 194 - // Get content by type 195 - getContentByType($type: string): ContentItem[] { 196 - return this.contentFeed.items.filter(item => item.$type === $type); 197 - } 198 - 199 - // Get content by collection 200 - getContentByCollection(collection: string): ContentItem[] { 201 - return this.contentFeed.items.filter(item => item.collection === collection); 202 - } 203 - 204 - // Get galleries (using specialized service) 205 - async getGalleries(identifier: string): Promise<any[]> { 206 - return await this.grainGalleryService.getGalleries(identifier); 207 - } 208 - 209 - // Filter content by function 210 - filterContent(filterFn: (item: ContentItem) => boolean): ContentItem[] { 211 - return this.contentFeed.items.filter(filterFn); 212 - } 213 - 214 - // Search content 215 - searchContent(query: string): ContentItem[] { 216 - const lowerQuery = query.toLowerCase(); 217 - return this.contentFeed.items.filter(item => { 218 - const text = JSON.stringify(item.value).toLowerCase(); 219 - return text.includes(lowerQuery); 220 - }); 221 - } 222 - 223 - // Stop streaming 224 - stopStreaming(): void { 225 - if (this.isStreaming) { 226 - this.jetstream.stopStreaming(); 227 - this.isStreaming = false; 228 - } 229 - } 230 - 231 - // Infer service from record type and collection 232 - private inferService($type: string, collection: string): string { 233 - if (collection.startsWith('grain.social') || $type.includes('grain')) return 'grain.social'; 234 - if (collection.startsWith('app.bsky')) return 'bsky.app'; 235 - if (collection.startsWith('sh.tangled')) return 'sh.tangled'; 236 - return 'unknown'; 237 - } 238 - 239 - // Event system for UI updates 240 - private listeners: { 241 - onContentUpdate?: (item: ContentItem) => void; 242 - onContentAdd?: (item: ContentItem) => void; 243 - onContentRemove?: (item: ContentItem) => void; 244 - } = {}; 245 - 246 - onContentUpdate(callback: (item: ContentItem) => void): void { 247 - this.listeners.onContentUpdate = callback; 248 - } 249 - 250 - onContentAdd(callback: (item: ContentItem) => void): void { 251 - this.listeners.onContentAdd = callback; 252 - } 253 - 254 - onContentRemove(callback: (item: ContentItem) => void): void { 255 - this.listeners.onContentRemove = callback; 256 - } 257 - 258 - private emitContentUpdate(item: ContentItem): void { 259 - this.listeners.onContentUpdate?.(item); 260 - if (item.operation === 'create') { 261 - this.listeners.onContentAdd?.(item); 262 - } else if (item.operation === 'delete') { 263 - this.listeners.onContentRemove?.(item); 264 - } 265 - } 266 - 267 - // Get streaming status 268 - getStreamingStatus(): 'streaming' | 'stopped' { 269 - return this.isStreaming ? 'streaming' : 'stopped'; 270 - } 271 - }
-148
src/lib/types/atproto.ts
··· 1 - // Base ATproto record types 2 - export interface AtprotoRecord { 3 - uri: string; 4 - cid: string; 5 - value: any; 6 - indexedAt: string; 7 - } 8 - 9 - // Bluesky post types with proper embed handling 10 - export interface BlueskyPost { 11 - text: string; 12 - createdAt: string; 13 - embed?: { 14 - $type: 'app.bsky.embed.images' | 'app.bsky.embed.external' | 'app.bsky.embed.record'; 15 - images?: Array<{ 16 - alt?: string; 17 - image: { 18 - $type: 'blob'; 19 - ref: { 20 - $link: string; 21 - }; 22 - mimeType: string; 23 - size: number; 24 - }; 25 - aspectRatio?: { 26 - width: number; 27 - height: number; 28 - }; 29 - }>; 30 - external?: { 31 - uri: string; 32 - title: string; 33 - description?: string; 34 - }; 35 - record?: { 36 - uri: string; 37 - cid: string; 38 - }; 39 - }; 40 - author?: { 41 - displayName?: string; 42 - handle?: string; 43 - }; 44 - reply?: { 45 - root: { 46 - uri: string; 47 - cid: string; 48 - }; 49 - parent: { 50 - uri: string; 51 - cid: string; 52 - }; 53 - }; 54 - facets?: Array<{ 55 - index: { 56 - byteStart: number; 57 - byteEnd: number; 58 - }; 59 - features: Array<{ 60 - $type: string; 61 - [key: string]: any; 62 - }>; 63 - }>; 64 - langs?: string[]; 65 - uri?: string; 66 - cid?: string; 67 - } 68 - 69 - // Custom lexicon types (to be extended) 70 - export interface CustomLexiconRecord { 71 - $type: string; 72 - [key: string]: any; 73 - } 74 - 75 - // Whitewind blog post type 76 - export interface WhitewindBlogPost extends CustomLexiconRecord { 77 - $type: 'app.bsky.actor.profile#whitewindBlogPost'; 78 - title: string; 79 - content: string; 80 - publishedAt: string; 81 - tags?: string[]; 82 - } 83 - 84 - // Leaflet publication type 85 - export interface LeafletPublication extends CustomLexiconRecord { 86 - $type: 'app.bsky.actor.profile#leafletPublication'; 87 - title: string; 88 - content: string; 89 - publishedAt: string; 90 - category?: string; 91 - } 92 - 93 - // Grain social image gallery type 94 - export interface GrainImageGallery extends CustomLexiconRecord { 95 - $type: 'app.bsky.actor.profile#grainImageGallery'; 96 - title: string; 97 - description?: string; 98 - images: Array<{ 99 - alt: string; 100 - url: string; 101 - }>; 102 - createdAt: string; 103 - } 104 - 105 - // Generic grain gallery post type (for posts that contain galleries) 106 - export interface GrainGalleryPost extends CustomLexiconRecord { 107 - $type: 'app.bsky.feed.post#grainGallery' | 'app.bsky.feed.post#grainImageGallery'; 108 - text?: string; 109 - createdAt: string; 110 - embed?: { 111 - $type: 'app.bsky.embed.images'; 112 - images?: Array<{ 113 - alt?: string; 114 - image: { 115 - $type: 'blob'; 116 - ref: string; 117 - mimeType: string; 118 - size: number; 119 - }; 120 - aspectRatio?: { 121 - width: number; 122 - height: number; 123 - }; 124 - }>; 125 - }; 126 - } 127 - 128 - // Union type for all supported content types 129 - export type SupportedContentType = 130 - | BlueskyPost 131 - | WhitewindBlogPost 132 - | LeafletPublication 133 - | GrainImageGallery 134 - | GrainGalleryPost; 135 - 136 - // Component registry type 137 - export interface ContentComponent { 138 - type: string; 139 - component: any; 140 - props?: Record<string, any>; 141 - } 142 - 143 - // Feed configuration type 144 - export interface FeedConfig { 145 - uri: string; 146 - limit?: number; 147 - filter?: (record: AtprotoRecord) => boolean; 148 - }
-145
src/lib/types/generator.ts
··· 1 - import type { DiscoveredLexicon } from '../atproto/discovery'; 2 - 3 - export interface GeneratedType { 4 - name: string; 5 - interface: string; 6 - $type: string; 7 - properties: Record<string, any>; 8 - service: string; 9 - collection: string; 10 - } 11 - 12 - export class TypeGenerator { 13 - private generatedTypes: Map<string, GeneratedType> = new Map(); 14 - 15 - // Generate TypeScript interface from a discovered lexicon 16 - generateTypeFromLexicon(lexicon: DiscoveredLexicon): GeneratedType { 17 - const $type = lexicon.$type; 18 - 19 - // Skip if already generated 20 - if (this.generatedTypes.has($type)) { 21 - return this.generatedTypes.get($type)!; 22 - } 23 - 24 - const typeName = this.generateTypeName($type); 25 - const interfaceCode = this.generateInterfaceCode(typeName, lexicon); 26 - 27 - const generatedType: GeneratedType = { 28 - name: typeName, 29 - interface: interfaceCode, 30 - $type, 31 - properties: lexicon.properties, 32 - service: lexicon.service, 33 - collection: lexicon.collection 34 - }; 35 - 36 - this.generatedTypes.set($type, generatedType); 37 - return generatedType; 38 - } 39 - 40 - // Generate type name from $type 41 - private generateTypeName($type: string): string { 42 - const parts = $type.split('#'); 43 - if (parts.length > 1) { 44 - return parts[1].charAt(0).toUpperCase() + parts[1].slice(1); 45 - } 46 - 47 - const lastPart = $type.split('.').pop() || 'Unknown'; 48 - return lastPart.charAt(0).toUpperCase() + lastPart.slice(1); 49 - } 50 - 51 - // Generate TypeScript interface code 52 - private generateInterfaceCode(name: string, lexicon: DiscoveredLexicon): string { 53 - const propertyLines = Object.entries(lexicon.properties).map(([key, type]) => { 54 - return ` ${key}: ${type};`; 55 - }); 56 - 57 - return `export interface ${name} extends CustomLexiconRecord { 58 - $type: '${lexicon.$type}'; 59 - ${propertyLines.join('\n')} 60 - }`; 61 - } 62 - 63 - // Generate all types from discovered lexicons 64 - generateTypesFromLexicons(lexicons: DiscoveredLexicon[]): GeneratedType[] { 65 - const types: GeneratedType[] = []; 66 - 67 - lexicons.forEach(lexicon => { 68 - const type = this.generateTypeFromLexicon(lexicon); 69 - types.push(type); 70 - }); 71 - 72 - return types; 73 - } 74 - 75 - // Get all generated types 76 - getAllGeneratedTypes(): GeneratedType[] { 77 - return Array.from(this.generatedTypes.values()); 78 - } 79 - 80 - // Generate complete types file content 81 - generateTypesFile(lexicons: DiscoveredLexicon[]): string { 82 - const types = this.generateTypesFromLexicons(lexicons); 83 - 84 - if (types.length === 0) { 85 - return '// No types generated'; 86 - } 87 - 88 - const imports = `import type { CustomLexiconRecord } from './atproto';`; 89 - const interfaces = types.map(type => type.interface).join('\n\n'); 90 - const unionType = this.generateUnionType(types); 91 - const serviceGroups = this.generateServiceGroups(types); 92 - 93 - return `${imports} 94 - 95 - ${interfaces} 96 - 97 - ${unionType} 98 - 99 - ${serviceGroups}`; 100 - } 101 - 102 - // Generate union type for all generated types 103 - private generateUnionType(types: GeneratedType[]): string { 104 - const typeNames = types.map(t => t.name); 105 - return `// Union type for all generated content types 106 - export type GeneratedContentType = ${typeNames.join(' | ')};`; 107 - } 108 - 109 - // Generate service-specific type groups 110 - private generateServiceGroups(types: GeneratedType[]): string { 111 - const serviceGroups = new Map<string, GeneratedType[]>(); 112 - 113 - types.forEach(type => { 114 - if (!serviceGroups.has(type.service)) { 115 - serviceGroups.set(type.service, []); 116 - } 117 - serviceGroups.get(type.service)!.push(type); 118 - }); 119 - 120 - let serviceGroupsCode = ''; 121 - serviceGroups.forEach((types, service) => { 122 - const typeNames = types.map(t => t.name); 123 - serviceGroupsCode += ` 124 - // ${service} types 125 - export type ${this.capitalizeService(service)}ContentType = ${typeNames.join(' | ')};`; 126 - }); 127 - 128 - return serviceGroupsCode; 129 - } 130 - 131 - // Capitalize service name for type name 132 - private capitalizeService(service: string): string { 133 - return service.split('.').map(part => 134 - part.charAt(0).toUpperCase() + part.slice(1) 135 - ).join(''); 136 - } 137 - 138 - // Clear all generated types 139 - clear(): void { 140 - this.generatedTypes.clear(); 141 - } 142 - } 143 - 144 - // Global type generator instance 145 - export const typeGenerator = new TypeGenerator();
+59
src/pages/blog/[rkey].astro
··· 1 + --- 2 + import Layout from '../../layouts/Layout.astro'; 3 + import { AtprotoBrowser } from '../../lib/atproto/atproto-browser'; 4 + import { loadConfig } from '../../lib/config/site'; 5 + import WhitewindBlogPost from '../../components/content/WhitewindBlogPost.astro'; 6 + 7 + export async function getStaticPaths() { 8 + const config = loadConfig(); 9 + const browser = new AtprotoBrowser(); 10 + const paths: Array<{ params: { rkey: string } }> = []; 11 + try { 12 + const records = await browser.getAllCollectionRecords(config.atproto.handle, 'com.whtwnd.blog.entry', 2000); 13 + for (const rec of records) { 14 + if (rec.value?.$type === 'com.whtwnd.blog.entry') { 15 + const rkey = rec.uri.split('/').pop(); 16 + if (rkey) paths.push({ params: { rkey } }); 17 + } 18 + } 19 + } catch (e) { 20 + console.error('getStaticPaths whitewind', e); 21 + } 22 + return paths; 23 + } 24 + 25 + const { rkey } = Astro.params as Record<string, string>; 26 + 27 + const config = loadConfig(); 28 + const browser = new AtprotoBrowser(); 29 + 30 + let record: any = null; 31 + let title = 'Post'; 32 + 33 + try { 34 + const did = config.atproto.did || (await browser.resolveHandle(config.atproto.handle)); 35 + if (did) { 36 + const uri = `at://${did}/com.whtwnd.blog.entry/${rkey}`; 37 + const rec = await browser.getRecord(uri); 38 + if (rec && rec.value?.$type === 'com.whtwnd.blog.entry') { 39 + record = rec.value; 40 + title = record.title || title; 41 + } 42 + } 43 + } catch (e) { 44 + console.error('Error loading whitewind post', e); 45 + } 46 + --- 47 + 48 + <Layout title={title}> 49 + <div class="container mx-auto px-4 py-8"> 50 + {record ? ( 51 + <div class="max-w-3xl mx-auto"> 52 + <WhitewindBlogPost record={record} showTags={true} showTimestamp={true} /> 53 + </div> 54 + ) : ( 55 + <div class="text-center text-gray-500 dark:text-gray-400 py-16">Post not found.</div> 56 + )} 57 + </div> 58 + </Layout> 59 +
+89
src/pages/blog.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { AtprotoBrowser } from '../lib/atproto/atproto-browser'; 4 + import { loadConfig } from '../lib/config/site'; 5 + import type { ComWhtwndBlogEntryRecord } from '../lib/generated/com-whtwnd-blog-entry'; 6 + 7 + const config = loadConfig(); 8 + const browser = new AtprotoBrowser(); 9 + 10 + // Fetch Whitewind blog posts from the repo using generated types 11 + let posts: Array<{ 12 + uri: string; 13 + record: ComWhtwndBlogEntryRecord; 14 + createdAt: string; 15 + }> = []; 16 + 17 + try { 18 + const records = await browser.getAllCollectionRecords(config.atproto.handle, 'com.whtwnd.blog.entry', 2000); 19 + posts = records 20 + .filter((r: any) => r.value?.$type === 'com.whtwnd.blog.entry') 21 + .map((r: any) => { 22 + const record = r.value as ComWhtwndBlogEntryRecord; 23 + const createdAt = (record as any).createdAt || r.indexedAt; 24 + return { 25 + uri: r.uri, 26 + record, 27 + createdAt, 28 + }; 29 + }); 30 + posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 31 + } catch (e) { 32 + console.error('Error loading whitewind posts', e); 33 + } 34 + 35 + function excerpt(text: string, maxChars = 240) { 36 + let t = text 37 + .replace(/!\[[^\]]*\]\([^\)]+\)/g, ' ') 38 + .replace(/\[[^\]]+\]\(([^\)]+)\)/g, '$1') 39 + .replace(/`{3}[\s\S]*?`{3}/g, ' ') 40 + .replace(/`([^`]+)`/g, '$1') 41 + .replace(/^#{1,6}\s+/gm, '') 42 + .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1'); 43 + t = t.replace(/\s+/g, ' ').trim(); 44 + if (t.length <= maxChars) return t; 45 + return t.slice(0, maxChars).trimEnd() + 'โ€ฆ'; 46 + } 47 + 48 + function postPathFromUri(uri: string) { 49 + // at://did/.../<collection>/<rkey> 50 + const parts = uri.split('/'); 51 + const rkey = parts[parts.length - 1]; 52 + return `/blog/${rkey}`; 53 + } 54 + --- 55 + 56 + <Layout title="Blog"> 57 + <div class="container mx-auto px-4 py-8"> 58 + <header class="text-center mb-12"> 59 + <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">Blog</h1> 60 + <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">Writing powered by the Whitewind lexicon</p> 61 + </header> 62 + 63 + <main class="max-w-3xl mx-auto"> 64 + {posts.length === 0 ? ( 65 + <div class="text-center text-gray-500 dark:text-gray-400 py-16">No posts yet.</div> 66 + ) : ( 67 + <div class="space-y-8"> 68 + {posts.map((p) => ( 69 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> 70 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 71 + <a href={postPathFromUri(p.uri)} class="hover:underline"> 72 + {p.record.title || 'Untitled'} 73 + </a> 74 + </h2> 75 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-3"> 76 + {(() => { 77 + const d = new Date(p.createdAt); 78 + return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); 79 + })()} 80 + </div> 81 + <p class="text-gray-700 dark:text-gray-300">{excerpt(p.record.content || '')}</p> 82 + </article> 83 + ))} 84 + </div> 85 + )} 86 + </main> 87 + </div> 88 + </Layout> 89 +
+30 -162
src/pages/index.astro
··· 1 1 --- 2 2 import Layout from '../layouts/Layout.astro'; 3 3 import ContentFeed from '../components/content/ContentFeed.astro'; 4 + import StatusUpdate from '../components/content/StatusUpdate.astro'; 4 5 import { loadConfig } from '../lib/config/site'; 5 6 6 7 const config = loadConfig(); 7 8 --- 8 9 9 - <Layout title="Home Page"> 10 + <Layout title="Home"> 10 11 <div class="container mx-auto px-4 py-8"> 11 - <header class="text-center mb-12"> 12 + <header class="mb-8"> 12 13 <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> 13 - Welcome to {config.site.title || 'Tynanverse'} 14 + Welcome to {config.site.title} 14 15 </h1> 15 - <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> 16 - {config.site.description || 'A personal website powered by ATproto, made by Tynan'} 16 + <p class="text-xl text-gray-600 dark:text-gray-300 mb-6"> 17 + {config.site.description} 17 18 </p> 19 + <nav class="flex space-x-4"> 20 + <a href="/" class="text-blue-600 dark:text-blue-400 hover:underline">Home</a> 21 + <a href="/blog" class="text-blue-600 dark:text-blue-400 hover:underline">Blog</a> 22 + <a href="/galleries" class="text-blue-600 dark:text-blue-400 hover:underline">Galleries</a> 23 + <a href="/leaflets" class="text-blue-600 dark:text-blue-400 hover:underline">Leaflets</a> 24 + </nav> 18 25 </header> 19 26 20 - <main class="max-w-4xl mx-auto space-y-12"> 21 - <!-- My Posts Feed --> 22 - {config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? ( 23 - <> 24 - <section> 25 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 26 - My Posts 27 - </h2> 28 - <ContentFeed 29 - handle={config.atproto.handle} 30 - limit={10} 31 - showAuthor={false} 32 - showTimestamp={true} 33 - /> 34 - </section> 35 - 36 - 37 - 38 - <section> 39 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 40 - Explore More 41 - </h2> 42 - <div class="grid md:grid-cols-2 gap-6"> 43 - <a href="/content-feed" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 44 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 45 - Content Feed 46 - </h3> 47 - <p class="text-gray-600 dark:text-gray-400"> 48 - All your ATProto content with real-time updates and streaming. 49 - </p> 50 - </a> 51 - <a href="/galleries" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 52 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 53 - Image Galleries 54 - </h3> 55 - <p class="text-gray-600 dark:text-gray-400"> 56 - View my grain.social image galleries and photo collections. 57 - </p> 58 - </a> 59 - <a href="/galleries-unified" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 60 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 61 - Unified Galleries 62 - </h3> 63 - <p class="text-gray-600 dark:text-gray-400"> 64 - Galleries with real-time updates using the content system. 65 - </p> 66 - </a> 67 - <a href="/gallery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 68 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 69 - Gallery System Test 70 - </h3> 71 - <p class="text-gray-600 dark:text-gray-400"> 72 - Test the gallery service and display components with detailed debugging. 73 - </p> 74 - </a> 75 - <a href="/content-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 76 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 77 - Content Rendering Test 78 - </h3> 79 - <p class="text-gray-600 dark:text-gray-400"> 80 - Test the content rendering system with type-safe components and filters. 81 - </p> 82 - </a> 83 - <a href="/gallery-debug" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 84 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 85 - Gallery Structure Debug 86 - </h3> 87 - <p class="text-gray-600 dark:text-gray-400"> 88 - Examine Grain.social collection structure and relationships. 89 - </p> 90 - </a> 91 - <a href="/grain-gallery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 92 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 93 - Grain Gallery Test 94 - </h3> 95 - <p class="text-gray-600 dark:text-gray-400"> 96 - Test the Grain.social gallery grouping and display system. 97 - </p> 98 - </a> 99 - <a href="/api-debug" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 100 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 101 - API Debug 102 - </h3> 103 - <p class="text-gray-600 dark:text-gray-400"> 104 - Debug ATProto API calls and configuration issues. 105 - </p> 106 - </a> 107 - <a href="/simple-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 108 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 109 - Simple API Test 110 - </h3> 111 - <p class="text-gray-600 dark:text-gray-400"> 112 - Basic HTTP request test to ATProto APIs. 113 - </p> 114 - </a> 115 - <a href="/discovery-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 116 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 117 - Discovery Test 118 - </h3> 119 - <p class="text-gray-600 dark:text-gray-400"> 120 - Build-time collection discovery and type generation. 121 - </p> 122 - </a> 123 - <a href="/simplified-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 124 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 125 - Simplified Content Test 126 - </h3> 127 - <p class="text-gray-600 dark:text-gray-400"> 128 - Test the simplified content service approach based on reference repository patterns. 129 - </p> 130 - </a> 131 - <a href="/jetstream-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 132 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 133 - Jetstream Test 134 - </h3> 135 - <p class="text-gray-600 dark:text-gray-400"> 136 - ATProto sync API streaming with DID filtering (like atptools) - low latency, real-time. 137 - </p> 138 - </a> 139 - <a href="/atproto-browser-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 140 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 141 - ATProto Browser Test 142 - </h3> 143 - <p class="text-gray-600 dark:text-gray-400"> 144 - Browse ATProto accounts and records like atptools - explore collections and records. 145 - </p> 146 - </a> 147 - <a href="/lexicon-generator-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 148 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 149 - Lexicon Generator Test 150 - </h3> 151 - <p class="text-gray-600 dark:text-gray-400"> 152 - Generate TypeScript types for all lexicons discovered in your configured repository. 153 - </p> 154 - </a> 155 - </div> 156 - </section> 157 - </> 158 - ) : ( 159 - <section> 160 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 161 - Configuration Required 162 - </h2> 163 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6"> 164 - <p class="text-yellow-800 dark:text-yellow-200 mb-4"> 165 - To display your posts, please configure your Bluesky handle in the environment variables. 166 - </p> 167 - <div class="text-sm text-yellow-700 dark:text-yellow-300"> 168 - <p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p> 169 - <pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto"> 170 - ATPROTO_HANDLE=your-handle.bsky.social 171 - SITE_TITLE=Your Site Title 172 - SITE_AUTHOR=Your Name</pre> 173 - </div> 174 - </div> 175 - </section> 176 - )} 27 + <main> 28 + <section class="mb-8"> 29 + <h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4"> 30 + Current Status 31 + </h2> 32 + <StatusUpdate /> 33 + </section> 34 + 35 + <section class="mb-8"> 36 + <h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4"> 37 + Latest Posts 38 + </h2> 39 + <ContentFeed 40 + limit={10} 41 + showTimestamp={true} 42 + live={true} 43 + /> 44 + </section> 177 45 </main> 178 46 </div> 179 47 </Layout>
+60
src/pages/leaflets/[leaflet].astro
··· 1 + --- 2 + import Layout from '../../layouts/Layout.astro'; 3 + import { getCollection, getEntry, render } from "astro:content"; 4 + 5 + export async function getStaticPaths() { 6 + const documents = await getCollection("documents"); 7 + return documents.map((document) => ({ 8 + params: { leaflet: document.id }, 9 + props: document, 10 + })); 11 + } 12 + 13 + const document = await getEntry("documents", Astro.params.leaflet); 14 + 15 + if (!document) { 16 + throw new Error(`Document with id "${Astro.params.leaflet}" not found`); 17 + } 18 + 19 + const { Content } = await render(document); 20 + --- 21 + 22 + <Layout title={document.data.title}> 23 + <div class="container mx-auto px-4 py-8"> 24 + <header class="mb-8"> 25 + <nav class="mb-6"> 26 + <a href="/leaflets" class="text-blue-600 dark:text-blue-400 hover:underline"> 27 + โ† Back to Leaflets 28 + </a> 29 + </nav> 30 + 31 + <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> 32 + {document.data.title} 33 + </h1> 34 + 35 + {document.data.description && ( 36 + <p class="text-xl text-gray-600 dark:text-gray-400 mb-6"> 37 + {document.data.description} 38 + </p> 39 + )} 40 + 41 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-6"> 42 + {document.data.publishedAt && ( 43 + <span> 44 + {new Date(document.data.publishedAt).toLocaleDateString('en-US', { 45 + year: 'numeric', 46 + month: 'long', 47 + day: 'numeric', 48 + })} 49 + </span> 50 + )} 51 + </div> 52 + </header> 53 + 54 + <main class="max-w-4xl mx-auto"> 55 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8"> 56 + <Content /> 57 + </article> 58 + </main> 59 + </div> 60 + </Layout>
+98
src/pages/leaflets.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { getCollection } from "astro:content"; 4 + import { loadConfig } from '../lib/config/site'; 5 + 6 + const config = loadConfig(); 7 + const documents = await getCollection("documents"); 8 + 9 + // Sort documents by published date (newest first) 10 + const sortedDocuments = documents.sort((a, b) => { 11 + const dateA = a.data.publishedAt ? new Date(a.data.publishedAt).getTime() : 0; 12 + const dateB = b.data.publishedAt ? new Date(b.data.publishedAt).getTime() : 0; 13 + return dateB - dateA; 14 + }); 15 + --- 16 + 17 + <Layout title="Leaflet Documents"> 18 + <div class="container mx-auto px-4 py-8"> 19 + <header class="text-center mb-12"> 20 + <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> 21 + Leaflet Documents 22 + </h1> 23 + <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> 24 + A collection of my leaflet.pub documents 25 + </p> 26 + </header> 27 + 28 + <main class="max-w-4xl mx-auto"> 29 + {config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? ( 30 + sortedDocuments.length > 0 ? ( 31 + <div class="space-y-6"> 32 + {sortedDocuments.map((document) => ( 33 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> 34 + <header class="mb-4"> 35 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 36 + <a href={`/leaflets/${document.id}`} class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"> 37 + {document.data.title} 38 + </a> 39 + </h2> 40 + 41 + {document.data.description && ( 42 + <p class="text-gray-600 dark:text-gray-400 mb-3"> 43 + {document.data.description} 44 + </p> 45 + )} 46 + 47 + <div class="text-sm text-gray-500 dark:text-gray-400"> 48 + {document.data.publishedAt && ( 49 + <span> 50 + Published: {new Date(document.data.publishedAt).toLocaleDateString('en-US', { 51 + year: 'numeric', 52 + month: 'long', 53 + day: 'numeric', 54 + })} 55 + </span> 56 + )} 57 + </div> 58 + </header> 59 + </article> 60 + ))} 61 + </div> 62 + ) : ( 63 + <div class="text-center py-12"> 64 + <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-8"> 65 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4"> 66 + No Leaflet Documents Found 67 + </h3> 68 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 69 + No leaflet.pub documents were found for your account. 70 + </p> 71 + <p class="text-sm text-gray-500 dark:text-gray-500"> 72 + Make sure you have created documents using leaflet.pub and they are properly indexed. 73 + </p> 74 + </div> 75 + </div> 76 + ) 77 + ) : ( 78 + <div class="text-center py-12"> 79 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8"> 80 + <h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4"> 81 + Configuration Required 82 + </h3> 83 + <p class="text-yellow-700 dark:text-yellow-300 mb-4"> 84 + To display your Leaflet documents, please configure your Bluesky handle in the environment variables. 85 + </p> 86 + <div class="text-sm text-yellow-600 dark:text-yellow-400"> 87 + <p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p> 88 + <pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto"> 89 + ATPROTO_HANDLE=your-handle.bsky.social 90 + SITE_TITLE=Your Site Title 91 + SITE_AUTHOR=Your Name</pre> 92 + </div> 93 + </div> 94 + </div> 95 + )} 96 + </main> 97 + </div> 98 + </Layout>
+17
src/pages/now.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import StatusUpdate from '../components/content/StatusUpdate.astro'; 4 + --- 5 + 6 + <Layout title="Now"> 7 + <main class="max-w-2xl mx-auto py-8 px-4 space-y-8"> 8 + <section> 9 + <h1 class="text-2xl font-bold mb-2">Now</h1> 10 + <p>This is the now page.</p> 11 + </section> 12 + <section> 13 + <h2 class="text-xl font-bold mb-2">Status</h2> 14 + <StatusUpdate /> 15 + </section> 16 + </main> 17 + </Layout>