Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

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

docs docs docs

+737 -288
+208 -139
docs/api-reference.md
··· 5 5 ## Base URL 6 6 7 7 ``` 8 - https://your-api-domain.com/xrpc/ 8 + https://api.slices.network/xrpc/ 9 9 ``` 10 10 11 11 ## Authentication ··· 19 19 20 20 Read operations typically work without authentication. 21 21 22 + ## Dynamic Collection Endpoints 23 + 24 + For each collection in your slice, the following endpoints are automatically 25 + generated: 26 + 27 + ### `[collection].getRecords` 28 + 29 + Get records in a collection. 30 + 31 + **Method**: GET 32 + 33 + **Parameters**: 34 + 35 + - `slice` (string, required): Slice URI 36 + - `limit` (number, optional): Maximum records (default: 50) 37 + - `cursor` (string, optional): Pagination cursor 38 + - `where` (object, optional): Filter conditions using field-specific queries 39 + - `sortBy` (array, optional): Sort specification with field and direction objects 40 + 41 + ### `[collection].getRecord` 42 + 43 + Get a single record. 44 + 45 + **Method**: GET 46 + 47 + **Parameters**: 48 + 49 + - `slice` (string, required): Slice URI 50 + - `uri` (string, required): Record URI 51 + 52 + ### `[collection].countRecords` 53 + 54 + Count records in a collection. 55 + 56 + **Method**: GET 57 + 58 + **Parameters**: 59 + 60 + - `slice` (string, required): Slice URI 61 + - `where` (object, optional): Filter conditions using field-specific queries 62 + - Other filter parameters (no limit/cursor) 63 + 64 + **Response**: 65 + 66 + ```json 67 + { 68 + "count": 150 69 + } 70 + ``` 71 + 72 + ### `[collection].createRecord` 73 + 74 + Create a new record. 75 + 76 + **Method**: POST 77 + 78 + **Authentication**: Required 79 + 80 + **Body**: 81 + 82 + ```json 83 + { 84 + "slice": "at://your-slice-uri", 85 + "record": { 86 + "$type": "com.recordcollector.album", 87 + "title": "Superunknown", 88 + "artist": "Soundgarden", 89 + "releaseDate": "1994-03-08", 90 + "condition": "Near Mint", 91 + "genre": ["grunge", "alternative metal"] 92 + }, 93 + "rkey": "3jklmno456" 94 + } 95 + ``` 96 + 97 + ### `[collection].updateRecord` 98 + 99 + Update an existing record. 100 + 101 + **Method**: POST 102 + 103 + **Authentication**: Required 104 + 105 + **Body**: 106 + 107 + ```json 108 + { 109 + "slice": "at://your-slice-uri", 110 + "rkey": "3xyz789abc", 111 + "record": { 112 + "$type": "com.recordcollector.album", 113 + "title": "Dirt", 114 + "artist": "Alice in Chains", 115 + "releaseDate": "1992-09-29", 116 + "condition": "Very Good Plus", 117 + "notes": "Minor sleeve wear, vinyl plays perfectly" 118 + } 119 + } 120 + ``` 121 + 122 + ### `[collection].deleteRecord` 123 + 124 + Delete a record. 125 + 126 + **Method**: POST 127 + 128 + **Authentication**: Required 129 + 130 + **Body**: 131 + 132 + ```json 133 + { 134 + "rkey": "3abc123xyz" 135 + } 136 + ``` 137 + 22 138 ## Core Endpoints 23 139 24 140 ### Slice Management 25 141 26 - #### `network.slices.slice.getRecords` 142 + ### `network.slices.slice.getRecords` 27 143 28 144 Get all slices. 29 145 ··· 33 149 34 150 - `limit` (number, optional): Maximum records to return (default: 50) 35 151 - `cursor` (string, optional): Pagination cursor 36 - - `sort` (string, optional): Sort field and order (e.g., `createdAt:desc`) 37 - - `author` (string, optional): Filter by author DID 38 - - `authors` (string[], optional): Filter by multiple author DIDs 152 + - `where` (object, optional): Filter conditions using field-specific queries 153 + - `sortBy` (array, optional): Sort specification with field and direction objects 39 154 40 155 **Response**: 41 156 ··· 59 174 } 60 175 ``` 61 176 62 - #### `network.slices.slice.getRecord` 177 + ### `network.slices.slice.getRecord` 63 178 64 179 Get a specific slice by URI. 65 180 ··· 71 186 72 187 **Response**: Single record object (same structure as getRecords item) 73 188 74 - #### `network.slices.slice.createRecord` 189 + ### `network.slices.slice.createRecord` 75 190 76 191 Create a new slice. 77 192 ··· 105 220 106 221 ### Slice Operations 107 222 108 - #### `network.slices.slice.stats` 223 + ### `network.slices.slice.stats` 109 224 110 225 Get statistics for a slice. 111 226 ··· 124 239 ```json 125 240 { 126 241 "success": true, 127 - "collections": ["com.example.post", "app.bsky.actor.profile"], 242 + "collections": ["com.recordcollector.album", "com.recordcollector.review"], 128 243 "collectionStats": [ 129 244 { 130 - "collection": "com.example.post", 131 - "recordCount": 150, 132 - "uniqueActors": 10 245 + "collection": "com.recordcollector.album", 246 + "recordCount": 427, 247 + "uniqueActors": 23 133 248 } 134 249 ], 135 250 "totalLexicons": 5, ··· 139 254 } 140 255 ``` 141 256 142 - #### `network.slices.slice.listSliceRecords` 257 + ### `network.slices.slice.listSliceRecords` 143 258 144 259 List records across multiple collections in a slice. 145 260 ··· 150 265 ```json 151 266 { 152 267 "slice": "at://your-slice-uri", 153 - "collections": ["com.example.post", "app.bsky.actor.profile"], 268 + "collections": ["com.recordcollector.album", "com.recordcollector.review"], 154 269 "authors": ["did:plc:optional-filter"], 155 270 "limit": 20, 156 271 "cursor": "pagination-cursor" ··· 164 279 "success": true, 165 280 "records": [ 166 281 { 167 - "uri": "at://did:plc:abc/com.example.post/xyz", 282 + "uri": "at://did:plc:abc/com.recordcollector.album/xyz", 168 283 "cid": "bafyrei...", 169 284 "did": "did:plc:abc", 170 - "collection": "com.example.post", 285 + "collection": "com.recordcollector.album", 171 286 "value": {/* record data */}, 172 287 "indexedAt": "2024-01-01T00:00:00Z" 173 288 } ··· 176 291 } 177 292 ``` 178 293 179 - #### `network.slices.slice.searchSliceRecords` 294 + ### `network.slices.slice.searchSliceRecords` 180 295 181 296 Search records across multiple collections in a slice by content. 182 297 ··· 187 302 ```json 188 303 { 189 304 "slice": "at://your-slice-uri", 190 - "collections": ["com.example.post", "app.bsky.actor.profile"], 305 + "collections": ["com.recordcollector.album", "com.recordcollector.review"], 191 306 "search": "search term", 192 307 "authors": ["did:plc:optional-filter"], 193 308 "limit": 20, ··· 202 317 "success": true, 203 318 "records": [ 204 319 { 205 - "uri": "at://did:plc:abc/com.example.post/xyz", 320 + "uri": "at://did:plc:abc/com.recordcollector.album/xyz", 206 321 "cid": "bafyrei...", 207 322 "did": "did:plc:abc", 208 - "collection": "com.example.post", 323 + "collection": "com.recordcollector.album", 209 324 "value": {/* record data */}, 210 325 "indexedAt": "2024-01-01T00:00:00Z" 211 326 } ··· 214 329 } 215 330 ``` 216 331 217 - #### `network.slices.slice.syncUserCollections` 332 + ### `network.slices.slice.syncUserCollections` 218 333 219 334 Synchronously sync collections for the authenticated user. 220 335 ··· 243 358 } 244 359 ``` 245 360 246 - #### `network.slices.slice.startSync` 361 + ### `network.slices.slice.startSync` 247 362 248 363 Start an asynchronous bulk sync job. 249 364 ··· 256 371 ```json 257 372 { 258 373 "slice": "at://your-slice-uri", 259 - "collections": ["com.example.post"], 374 + "collections": ["com.recordcollector.album"], 260 375 "externalCollections": ["app.bsky.actor.profile"], 261 376 "repos": ["did:plc:abc", "did:plc:xyz"], 262 377 "limitPerRepo": 100 ··· 273 388 } 274 389 ``` 275 390 276 - #### `network.slices.slice.codegen` 391 + ### `network.slices.slice.codegen` 277 392 278 393 Generate TypeScript client code. 279 394 ··· 297 412 } 298 413 ``` 299 414 300 - ## Dynamic Collection Endpoints 301 - 302 - For each collection in your slice, the following endpoints are automatically 303 - generated: 304 - 305 - ### `[collection].getRecords` 306 - 307 - Get records in a collection. 308 - 309 - **Method**: GET 310 - 311 - **Parameters**: 312 - 313 - - `slice` (string, required): Slice URI 314 - - `limit` (number, optional): Maximum records (default: 50) 315 - - `cursor` (string, optional): Pagination cursor 316 - - `sort` (string, optional): Sort specification 317 - - `author` (string, optional): Filter by author DID 318 - - `authors` (string[], optional): Filter by multiple DIDs 319 - 320 - ### `[collection].getRecord` 321 - 322 - Get a single record. 323 - 324 - **Method**: GET 325 - 326 - **Parameters**: 327 - 328 - - `slice` (string, required): Slice URI 329 - - `uri` (string, required): Record URI 330 - 331 - ### `[collection].countRecords` 332 - 333 - Count records in a collection. 334 - 335 - **Method**: GET 336 - 337 - **Parameters**: 338 - 339 - - `slice` (string, required): Slice URI 340 - - `author` (string, optional): Filter by author DID 341 - - `authors` (string[], optional): Filter by multiple DIDs 342 - - Other filter parameters (no limit/cursor) 343 - 344 - **Response**: 345 - 346 - ```json 347 - { 348 - "count": 150 349 - } 350 - ``` 351 - 352 - ### `[collection].createRecord` 353 - 354 - Create a new record. 355 - 356 - **Method**: POST 357 - 358 - **Authentication**: Required 359 - 360 - **Body**: 361 - 362 - ```json 363 - { 364 - "slice": "at://your-slice-uri", 365 - "record": { 366 - "$type": "collection.name" 367 - /* record fields */ 368 - }, 369 - "rkey": "optional-key" 370 - } 371 - ``` 372 - 373 - ### `[collection].updateRecord` 374 - 375 - Update an existing record. 376 - 377 - **Method**: POST 378 - 379 - **Authentication**: Required 380 - 381 - **Body**: 382 - 383 - ```json 384 - { 385 - "slice": "at://your-slice-uri", 386 - "rkey": "record-key", 387 - "record": { 388 - "$type": "collection.name" 389 - /* updated fields */ 390 - } 391 - } 392 - ``` 393 - 394 - ### `[collection].deleteRecord` 395 - 396 - Delete a record. 397 - 398 - **Method**: POST 399 - 400 - **Authentication**: Required 401 - 402 - **Body**: 403 - 404 - ```json 405 - { 406 - "rkey": "record-key" 407 - } 408 - ``` 409 - 410 415 ## Lexicon Management 411 416 412 417 ### `network.slices.lexicon.getRecords` ··· 448 453 "slice": "at://your-slice-uri", 449 454 "record": { 450 455 "$type": "network.slices.lexicon", 451 - "nsid": "com.example.post", 456 + "nsid": "com.recordcollector.album", 452 457 "definitions": "{\"lexicon\": 1, ...}", 453 458 "createdAt": "2024-01-01T00:00:00Z", 454 459 "slice": "at://your-slice-uri" ··· 557 562 } while (cursor); 558 563 ``` 559 564 565 + ## Filtering 566 + 567 + List endpoints support filtering using the `where` parameter with field-specific query operators: 568 + 569 + ### Filter Operators 570 + 571 + - `eq`: Exact match 572 + - `contains`: Partial text match (case-insensitive) 573 + - `in`: Match any value in array 574 + 575 + ### Examples 576 + 577 + **Exact match filtering:** 578 + 579 + ```json 580 + { 581 + "where": { 582 + "artist": { "eq": "Nirvana" }, 583 + "condition": { "eq": "Mint" } 584 + } 585 + } 586 + ``` 587 + 588 + **Text search filtering:** 589 + 590 + ```json 591 + { 592 + "where": { 593 + "title": { "contains": "nevermind" }, 594 + "genre": { "contains": "grunge" } 595 + } 596 + } 597 + ``` 598 + 599 + **Array filtering:** 600 + 601 + ```json 602 + { 603 + "where": { 604 + "condition": { "in": ["Mint", "Near Mint", "Very Good Plus"] }, 605 + "artist": { "in": ["Nirvana", "Pearl Jam", "Soundgarden"] } 606 + } 607 + } 608 + ``` 609 + 610 + **Global search across all fields:** 611 + 612 + ```json 613 + { 614 + "where": { 615 + "json": { "contains": "grunge" } 616 + } 617 + } 618 + ``` 619 + 560 620 ## Sorting 561 621 562 - Sort parameter format: `field:order` or `field1:order1,field2:order2` 622 + Sort parameter uses an array format with field and direction: 623 + 624 + ```json 625 + { 626 + "sortBy": [ 627 + { "field": "releaseDate", "direction": "desc" }, 628 + { "field": "title", "direction": "asc" } 629 + ] 630 + } 631 + ``` 563 632 564 633 Examples: 565 634 566 - - `createdAt:desc` - Newest first 567 - - `name:asc` - Alphabetical 568 - - `createdAt:desc,name:asc` - Newest first, then alphabetical 635 + - `[{ "field": "releaseDate", "direction": "desc" }]` - Newest releases first 636 + - `[{ "field": "artist", "direction": "asc" }]` - Alphabetical by artist 637 + - `[{ "field": "releaseDate", "direction": "desc" }, { "field": "title", "direction": "asc" }]` - Newest first, then alphabetical by title 569 638 570 639 ## Next Steps 571 640
+10 -8
docs/concepts.md
··· 34 34 ```json 35 35 { 36 36 "lexicon": 1, 37 - "id": "com.example.blogPost", 37 + "id": "com.recordcollector.album", 38 38 "defs": { 39 39 "main": { 40 40 "type": "record", 41 - "description": "A blog post record", 41 + "description": "A vinyl album record", 42 42 "record": { 43 43 "type": "object", 44 44 "properties": { 45 45 "title": { "type": "string" }, 46 - "content": { "type": "string" }, 47 - "publishedAt": { "type": "string", "format": "datetime" } 46 + "artist": { "type": "string" }, 47 + "releaseDate": { "type": "string", "format": "datetime" }, 48 + "condition": { "type": "string" } 48 49 }, 49 - "required": ["title", "content"] 50 + "required": ["title", "artist"] 50 51 } 51 52 } 52 53 } ··· 64 65 65 66 Lexicons follow reverse domain naming: 66 67 67 - - `com.example.post` - A post in the example.com namespace 68 + - `com.recordcollector.album` - An album in the recordcollector.com namespace 69 + - `com.recordcollector.review` - A vinyl review record 68 70 - `network.slices.slice` - Core slice record type 69 71 - `app.bsky.actor.profile` - Bluesky profile (external) 70 72 ··· 76 78 ### Primary Collections 77 79 78 80 Collections that match your slice's domain namespace. For example, if your slice 79 - domain is `com.example`, then `com.example.post` would be a primary collection. 81 + domain is `com.recordcollector`, then `com.recordcollector.album` would be a primary collection. 80 82 81 83 ### External Collections 82 84 ··· 254 256 const client = new AtProtoClient(apiUrl, sliceUri, oauthClient); 255 257 256 258 // Use nested structure matching lexicons 257 - await client.com.example.post.getRecords(); 259 + await client.com.recordcollector.album.getRecords(); 258 260 await client.app.bsky.actor.profile.getRecord({ uri }); 259 261 ``` 260 262
+33 -23
docs/getting-started.md
··· 15 15 ### 1. Clone the Repository 16 16 17 17 ```bash 18 - git clone https://github.com/your-org/slice 18 + git clone https://tangled.sh/@slices.network/slices 19 19 cd slice 20 20 ``` 21 21 ··· 98 98 Click "Create Slice" and provide: 99 99 100 100 - **Name**: A friendly name for your slice 101 - - **Domain**: Your namespace (e.g., `com.example`) 101 + - **Domain**: Your namespace (e.g., `com.recordcollector`) 102 102 103 103 ### 3. Define a Lexicon 104 104 ··· 108 108 ```json 109 109 { 110 110 "lexicon": 1, 111 - "id": "com.example.post", 111 + "id": "com.recordcollector.album", 112 112 "defs": { 113 113 "main": { 114 114 "type": "record", 115 - "description": "A blog post", 115 + "description": "A vinyl album record", 116 116 "record": { 117 117 "type": "object", 118 118 "properties": { 119 119 "title": { 120 120 "type": "string", 121 - "description": "Post title" 121 + "description": "Album title" 122 122 }, 123 - "content": { 123 + "artist": { 124 124 "type": "string", 125 - "description": "Post content" 125 + "description": "Artist or band name" 126 126 }, 127 - "createdAt": { 127 + "releaseDate": { 128 128 "type": "string", 129 129 "format": "datetime", 130 - "description": "Creation timestamp" 130 + "description": "Original release date" 131 131 }, 132 - "tags": { 132 + "genre": { 133 133 "type": "array", 134 134 "items": { 135 135 "type": "string" 136 136 }, 137 - "description": "Post tags" 137 + "description": "Music genres" 138 + }, 139 + "condition": { 140 + "type": "string", 141 + "description": "Vinyl condition (Mint, Near Mint, Very Good, etc.)" 142 + }, 143 + "notes": { 144 + "type": "string", 145 + "description": "Collector notes" 138 146 } 139 147 }, 140 - "required": ["title", "content", "createdAt"] 148 + "required": ["title", "artist", "releaseDate"] 141 149 } 142 150 } 143 151 } ··· 161 169 "at://did:plc:your-did/network.slices.slice/your-slice-id", 162 170 ); 163 171 164 - // Get posts 165 - const posts = await client.com.example.post.getRecords(); 172 + // Get albums 173 + const albums = await client.com.recordcollector.album.getRecords(); 166 174 167 - // Create a post 168 - const newPost = await client.com.example.post.createRecord({ 169 - title: "My First Post", 170 - content: "Hello from Slices!", 171 - createdAt: new Date().toISOString(), 172 - tags: ["introduction", "slices"], 175 + // Add a new album to your collection 176 + const newAlbum = await client.com.recordcollector.album.createRecord({ 177 + title: "Nevermind", 178 + artist: "Nirvana", 179 + releaseDate: "1991-09-24", 180 + genre: ["grunge", "alternative rock"], 181 + condition: "Near Mint", 182 + notes: "Original pressing, includes poster", 173 183 }); 174 184 175 - // Get a specific post 176 - const post = await client.com.example.post.getRecord({ 177 - uri: newPost.uri, 185 + // Get a specific album 186 + const album = await client.com.recordcollector.album.getRecord({ 187 + uri: newAlbum.uri, 178 188 }); 179 189 ``` 180 190
+147 -114
docs/sdk-usage.md
··· 23 23 ); 24 24 25 25 // Read operations work without auth 26 - const records = await client.com.example.post.getRecords(); 26 + const albums = await client.com.recordcollector.album.getRecords(); 27 27 ``` 28 28 29 29 ### With Authentication (Full Access) ··· 50 50 51 51 ## CRUD Operations 52 52 53 - ### Getting Records (Unified API) 53 + ### Getting Records 54 54 55 - The SDK uses a unified `getRecords` method that replaces the previous 56 - `listRecords` and `searchRecords` methods: 55 + The SDK uses `getRecords` for retrieving records: 57 56 58 57 ```typescript 59 - // Get all records 60 - const posts = await client.com.example.post.getRecords(); 58 + // Get all vinyl records 59 + const albums = await client.com.recordcollector.album.getRecords(); 61 60 62 61 // With pagination 63 - const page1 = await client.com.example.post.getRecords({ limit: 20 }); 64 - const page2 = await client.com.example.post.getRecords({ 62 + const page1 = await client.com.recordcollector.album.getRecords({ limit: 20 }); 63 + const page2 = await client.com.recordcollector.album.getRecords({ 65 64 limit: 20, 66 65 cursor: page1.cursor, 67 66 }); 68 67 69 68 // With filtering using where clause 70 - const userPosts = await client.com.example.post.getRecords({ 69 + const nirvanaAlbums = await client.com.recordcollector.album.getRecords({ 71 70 where: { 72 - did: { eq: "did:plc:user123" }, 71 + artist: { eq: "Nirvana" }, 73 72 }, 74 73 }); 75 74 76 75 // Text search in specific fields 77 - const searchResults = await client.com.example.post.getRecords({ 76 + const searchResults = await client.com.recordcollector.album.getRecords({ 78 77 where: { 79 - title: { contains: "typescript" }, 78 + title: { contains: "nevermind" }, 80 79 }, 81 80 }); 82 81 83 82 // Global text search across ALL fields using 'json' 84 - const globalSearch = await client.com.example.post.getRecords({ 83 + const globalSearch = await client.com.recordcollector.album.getRecords({ 85 84 where: { 86 - json: { contains: "typescript" }, 85 + json: { contains: "grunge" }, 87 86 }, 88 87 }); 89 88 90 89 // Combine multiple filters 91 - const filteredPosts = await client.com.example.post.getRecords({ 90 + const seattleGrunge = await client.com.recordcollector.album.getRecords({ 92 91 where: { 93 - did: { eq: "did:plc:user123" }, 94 - text: { contains: "guide" }, 92 + city: { eq: "Seattle" }, 93 + genre: { contains: "grunge" }, 95 94 }, 96 95 limit: 50, 97 96 }); 98 97 98 + // Advanced filtering with multiple conditions 99 + const complexFilter = await client.com.recordcollector.album.getRecords({ 100 + where: { 101 + artist: { contains: "alice" }, 102 + releaseDate: { gte: "1990-01-01" }, 103 + condition: { in: ["Mint", "Near Mint"] }, 104 + }, 105 + limit: 25, 106 + }); 107 + 108 + // Filtering with exact matches 109 + const exactMatch = await client.com.recordcollector.album.getRecords({ 110 + where: { 111 + artist: { eq: "Soundgarden" }, 112 + genre: { contains: "grunge" }, 113 + }, 114 + }); 115 + 116 + // Date range filtering 117 + const nineties = await client.com.recordcollector.album.getRecords({ 118 + where: { 119 + releaseDate: { 120 + gte: "1990-01-01", 121 + lte: "1999-12-31" 122 + }, 123 + }, 124 + }); 125 + 99 126 // With sorting 100 - const recentPosts = await client.com.example.post.getRecords({ 101 - sortBy: [{ field: "createdAt", direction: "desc" }], 127 + const recentAlbums = await client.com.recordcollector.album.getRecords({ 128 + sortBy: [{ field: "releaseDate", direction: "desc" }], 102 129 }); 103 130 104 131 // Multiple sort fields 105 - const sortedPosts = await client.com.example.post.getRecords({ 132 + const sortedAlbums = await client.com.recordcollector.album.getRecords({ 106 133 sortBy: [ 107 - { field: "createdAt", direction: "desc" }, 134 + { field: "releaseDate", direction: "desc" }, 108 135 { field: "title", direction: "asc" }, 109 136 ], 110 137 }); ··· 117 144 118 145 ```typescript 119 146 // Count all records 120 - const total = await client.com.example.post.countRecords(); 121 - console.log(`Total posts: ${total.count}`); 147 + const total = await client.com.recordcollector.album.countRecords(); 148 + console.log(`Total albums: ${total.count}`); 122 149 123 150 // Count with filtering 124 - const userPostCount = await client.com.example.post.countRecords({ 151 + const nirvanaCount = await client.com.recordcollector.album.countRecords({ 125 152 where: { 126 - did: { eq: "did:plc:user123" }, 153 + artist: { eq: "Nirvana" }, 127 154 }, 128 155 }); 129 156 130 157 // Count with text search 131 - const searchCount = await client.com.example.post.countRecords({ 158 + const searchCount = await client.com.recordcollector.album.countRecords({ 132 159 where: { 133 - title: { contains: "typescript" }, 160 + title: { contains: "nevermind" }, 134 161 }, 135 162 }); 136 163 137 164 // Count with multiple filters 138 - const filteredCount = await client.com.example.post.countRecords({ 165 + const filteredCount = await client.com.recordcollector.album.countRecords({ 139 166 where: { 140 - did: { eq: "did:plc:user123" }, 141 - json: { contains: "guide" }, 167 + artist: { eq: "Alice in Chains" }, 168 + json: { contains: "grunge" }, 142 169 }, 143 170 }); 144 171 145 172 // Count with OR conditions 146 - const orCount = await client.com.example.post.countRecords({ 173 + const orCount = await client.com.recordcollector.album.countRecords({ 147 174 where: { 148 - createdAt: { eq: "2025-09-03" }, 175 + releaseDate: { eq: "1991-09-24" }, 149 176 }, 150 177 orWhere: { 151 - title: { contains: "typescript" }, 152 - did: { eq: "did:plc:author" }, 178 + title: { contains: "nevermind" }, 179 + artist: { eq: "Pearl Jam" }, 153 180 }, 154 181 }); 155 182 156 - console.log(`Found ${filteredCount.count} matching posts`); 157 - console.log(`Found ${orCount.count} posts with OR conditions`); 183 + console.log(`Found ${filteredCount.count} matching albums`); 184 + console.log(`Found ${orCount.count} albums with OR conditions`); 158 185 ``` 159 186 160 187 ### Getting a Single Record 161 188 162 189 ```typescript 163 - const post = await client.com.example.post.getRecord({ 164 - uri: "at://did:plc:abc/com.example.post/3xyz", 190 + const album = await client.com.recordcollector.album.getRecord({ 191 + uri: "at://did:plc:abc/com.recordcollector.album/3jklmno456", 165 192 }); 166 193 167 - console.log(post.value.title); 168 - console.log(post.value.content); 194 + console.log(album.value.title); 195 + console.log(album.value.artist); 169 196 ``` 170 197 171 198 ### Creating Records 172 199 173 200 ```typescript 174 201 // Create with auto-generated key 175 - const newPost = await client.com.example.post.createRecord({ 176 - title: "My New Post", 177 - content: "This is the content", 178 - createdAt: new Date().toISOString(), 179 - tags: ["typescript", "atproto"], 202 + const newAlbum = await client.com.recordcollector.album.createRecord({ 203 + title: "In Utero", 204 + artist: "Nirvana", 205 + releaseDate: "1993-09-21", 206 + genre: ["grunge", "alternative rock"], 180 207 }); 181 208 182 - console.log(`Created: ${newPost.uri}`); 209 + console.log(`Created: ${newAlbum.uri}`); 183 210 184 211 // Create with custom key 185 - const customPost = await client.com.example.post.createRecord( 212 + const customAlbum = await client.com.recordcollector.album.createRecord( 186 213 { 187 - title: "Custom Key Post", 188 - content: "Using a custom record key", 189 - createdAt: new Date().toISOString(), 214 + title: "Badmotorfinger", 215 + artist: "Soundgarden", 216 + releaseDate: "1991-10-08", 190 217 }, 191 218 true, // useSelfRkey for singleton records like profiles 192 219 ); ··· 196 223 197 224 ```typescript 198 225 // Get the record key from the URI 199 - const uri = "at://did:plc:abc/com.example.post/3xyz"; 200 - const rkey = uri.split("/").pop(); // '3xyz' 226 + const uri = "at://did:plc:abc/com.recordcollector.album/3jklmno456"; 227 + const rkey = uri.split("/").pop(); // '3jklmno456' 201 228 202 - const updated = await client.com.example.post.updateRecord( 229 + const updated = await client.com.recordcollector.album.updateRecord( 203 230 rkey, 204 231 { 205 - title: "Updated Title", 206 - content: "Updated content", 207 - createdAt: new Date().toISOString(), 232 + title: "Nevermind (Remastered)", 233 + artist: "Nirvana", 234 + releaseDate: "1991-09-24", 208 235 updatedAt: new Date().toISOString(), 209 236 }, 210 237 ); ··· 215 242 ### Deleting Records 216 243 217 244 ```typescript 218 - const rkey = "3xyz"; 219 - await client.com.example.post.deleteRecord(rkey); 245 + const rkey = "3jklmno456"; 246 + await client.com.recordcollector.album.deleteRecord(rkey); 220 247 ``` 221 248 222 249 ## Working with External Collections ··· 243 270 244 271 ```typescript 245 272 // Read file as ArrayBuffer 246 - const file = await Deno.readFile("./image.jpg"); 273 + const file = await Deno.readFile("./nevermind-cover.jpg"); 247 274 248 275 // Upload blob 249 276 const blobResponse = await client.uploadBlob({ ··· 252 279 }); 253 280 254 281 // Use blob in a record 255 - const postWithImage = await client.com.example.post.createRecord({ 256 - title: "Post with Image", 257 - content: "Check out this image!", 258 - image: blobResponse.blob, 259 - createdAt: new Date().toISOString(), 282 + const albumWithArt = await client.com.recordcollector.album.createRecord({ 283 + title: "Nevermind", 284 + artist: "Nirvana", 285 + releaseDate: "1991-09-24", 286 + genre: ["grunge", "alternative rock"], 287 + condition: "Near Mint", 288 + albumArt: blobResponse.blob, 260 289 }); 261 290 ``` 262 291 ··· 351 380 352 381 ### Browse Slice Records 353 382 354 - #### Get Records from Multiple Collections (Unified API) 383 + #### Get Records from Multiple Collections 355 384 356 - The `getSliceRecords` method uses the same unified `where` clause approach: 385 + The `getSliceRecords` method uses the same `where` clause approach: 357 386 358 387 ```typescript 359 388 // Get records from specific collections ··· 412 441 413 442 ```typescript 414 443 // Search in title field only 415 - const titleSearch = await client.com.example.post.getRecords({ 444 + const titleSearch = await client.com.recordcollector.album.getRecords({ 416 445 where: { 417 - title: { contains: "javascript" }, 446 + title: { contains: "nevermind" }, 418 447 }, 419 448 }); 420 449 421 - // Search in description field 422 - const descSearch = await client.com.example.post.getRecords({ 450 + // Search in notes field 451 + const notesSearch = await client.com.recordcollector.album.getRecords({ 423 452 where: { 424 - description: { contains: "tutorial" }, 453 + notes: { contains: "original pressing" }, 425 454 }, 426 455 }); 427 456 ``` ··· 431 460 Use the special `json` field to search across **all fields** in a record: 432 461 433 462 ```typescript 434 - // Finds records containing "react" anywhere in their data 435 - const globalSearch = await client.com.example.post.getRecords({ 463 + // Finds records containing "grunge" anywhere in their data 464 + const globalSearch = await client.com.recordcollector.album.getRecords({ 436 465 where: { 437 - json: { contains: "react" }, 466 + json: { contains: "grunge" }, 438 467 }, 439 468 }); 440 469 441 - // This will match records where "react" appears in: 442 - // - title: "Learning React" 443 - // - content: "This post is about React development" 444 - // - tags: ["javascript", "react", "frontend"] 470 + // This will match records where "grunge" appears in: 471 + // - title: "Nevermind" 472 + // - artist: "Nirvana" 473 + // - genre: ["grunge", "alternative rock"] 474 + // - notes: "Classic grunge album from Seattle" 445 475 // - or any other field in the record 446 476 ``` 447 477 ··· 450 480 When using `getSliceRecords`, you can search across multiple collections: 451 481 452 482 ```typescript 453 - // Search for "tutorial" across all collections 483 + // Search for "seattle" across all collections 454 484 const crossCollectionSearch = await client.network.slices.slice.getSliceRecords( 455 485 { 456 486 where: { 457 - json: { contains: "tutorial" }, 487 + json: { contains: "seattle" }, 458 488 }, 459 489 }, 460 490 ); ··· 462 492 // Limit to specific collections 463 493 const specificSearch = await client.network.slices.slice.getSliceRecords({ 464 494 where: { 465 - collection: { in: ["com.example.post", "com.example.article"] }, 466 - json: { contains: "guide" }, 495 + collection: { in: ["com.recordcollector.album", "com.recordcollector.review"] }, 496 + json: { contains: "grunge" }, 467 497 }, 468 498 }); 469 499 ``` ··· 475 505 autocomplete for field names: 476 506 477 507 ```typescript 478 - // Find posts by either user1 OR user2 479 - const posts = await client.com.example.post.getRecords({ 508 + // Find albums by either Nirvana OR Alice in Chains 509 + const albums = await client.com.recordcollector.album.getRecords({ 480 510 orWhere: { 481 - did: { in: ["did:plc:user1", "did:plc:user2"] }, 511 + artist: { in: ["Nirvana", "Alice in Chains"] }, 482 512 }, 483 513 }); 484 514 485 - // Find posts that either have "typescript" in title OR are by a specific user 486 - const posts = await client.com.example.post.getRecords({ 515 + // Find albums that either have "nevermind" in title OR are by Soundgarden 516 + const albums = await client.com.recordcollector.album.getRecords({ 487 517 orWhere: { 488 - title: { contains: "typescript" }, 489 - did: { eq: "did:plc:alice" }, 518 + title: { contains: "nevermind" }, 519 + artist: { eq: "Soundgarden" }, 490 520 }, 491 521 }); 492 522 493 523 // Combining OR with regular AND conditions 494 - const posts = await client.com.example.post.getRecords({ 524 + const albums = await client.com.recordcollector.album.getRecords({ 495 525 where: { 496 - createdAt: { eq: "2025-09-03" }, // AND conditions 526 + releaseDate: { eq: "1991-09-24" }, // AND conditions 497 527 }, 498 528 orWhere: { // OR conditions 499 - title: { contains: "guide" }, 500 - did: { eq: "did:plc:user1" }, 529 + artist: { contains: "nirvana" }, 530 + genre: { contains: "grunge" }, 501 531 }, 502 532 }); 503 - // SQL: WHERE created_at = '2025-09-03' AND (title LIKE '%guide%' OR did = 'did:plc:user1') 533 + // SQL: WHERE release_date = '1991-09-24' AND (artist LIKE '%nirvana%' OR genre LIKE '%grunge%') 504 534 505 535 // OR queries work with cross-collection searches too 506 536 const crossCollectionOrSearch = await client.network.slices.slice 507 537 .getSliceRecords({ 508 538 where: { 509 - collection: { eq: "com.example.post" }, 539 + collection: { eq: "com.recordcollector.album" }, 510 540 }, 511 541 orWhere: { 512 - title: { contains: "javascript" }, 513 - tags: { contains: "tutorial" }, 542 + artist: { contains: "pearl jam" }, 543 + genre: { contains: "alternative rock" }, 514 544 }, 515 545 }); 516 546 517 547 // You get full autocomplete and type safety for field names in both where and orWhere 518 - const typedSearch = await client.com.example.post.getRecords({ 548 + const typedSearch = await client.com.recordcollector.album.getRecords({ 519 549 where: { 520 550 // TypeScript autocompletes valid field names here 521 - title: { contains: "react" }, 551 + condition: { contains: "mint" }, 522 552 }, 523 553 orWhere: { 524 554 // And also provides autocomplete here 525 - description: { contains: "tutorial" }, 526 - tags: { contains: "guide" }, 555 + artist: { contains: "soundgarden" }, 556 + genre: { contains: "grunge" }, 527 557 }, 528 558 }); 529 559 ``` ··· 610 640 611 641 ```typescript 612 642 // TypeScript knows the shape of your records 613 - const post = await client.com.example.post.getRecord({ uri }); 643 + const album = await client.com.recordcollector.album.getRecord({ uri }); 614 644 615 645 // Type error: property 'unknownField' does not exist 616 - // post.value.unknownField 646 + // album.value.unknownField 617 647 618 648 // Autocomplete works for all fields 619 - post.value.title; // string 620 - post.value.tags; // string[] 621 - post.value.createdAt; // string 649 + album.value.title; // string 650 + album.value.artist; // string 651 + album.value.genre; // string[] 652 + album.value.releaseDate; // string 622 653 623 654 // Creating records is type-checked 624 - await client.com.example.post.createRecord({ 625 - title: "Valid", 626 - content: "Also valid", 627 - createdAt: new Date().toISOString(), 655 + await client.com.recordcollector.album.createRecord({ 656 + title: "Dirt", 657 + artist: "Alice in Chains", 658 + releaseDate: "1992-09-29", 659 + genre: ["grunge", "alternative metal"], 660 + condition: "Very Good Plus", 628 661 // Type error: 'invalidField' is not assignable 629 662 // invalidField: "This will error" 630 663 }); ··· 636 669 637 670 ```typescript 638 671 // Process records in batches 639 - async function* getAllRecords() { 672 + async function* getAllAlbums() { 640 673 let cursor: string | undefined; 641 674 642 675 do { 643 - const batch = await client.com.example.post.getRecords({ 676 + const batch = await client.com.recordcollector.album.getRecords({ 644 677 limit: 100, 645 678 cursor, 646 679 }); ··· 651 684 } 652 685 653 686 // Use the generator 654 - for await (const record of getAllRecords()) { 655 - console.log(record.value.title); 687 + for await (const album of getAllAlbums()) { 688 + console.log(`${album.value.artist} - ${album.value.title}`); 656 689 } 657 690 ``` 658 691
+1
frontend/CLAUDE.md
··· 80 80 - `src/client.ts` - Generated AT Protocol client for API communication 81 81 - `src/config.ts` - Centralized configuration and service setup 82 82 - Environment variables required: `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_REDIRECT_URI`, `OAUTH_AIP_BASE_URL`, `API_URL`, `SLICE_URI` 83 + - Optional variables: `DOCS_PATH` (path to markdown documentation files, defaults to "../docs") 83 84 84 85 #### Rendering System 85 86 - `src/utils/render.tsx` - Unified HTML rendering with proper headers
+1
frontend/Dockerfile
··· 6 6 WORKDIR /app 7 7 8 8 COPY . . 9 + COPY ../docs ./docs 9 10 10 11 RUN deno cache src/main.ts 11 12
+196
frontend/src/features/docs/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { withAuth } from "../../routes/middleware.ts"; 3 + import { renderHTML } from "../../utils/render.tsx"; 4 + import { codeToHtml } from "jsr:@shikijs/shiki"; 5 + import { DocsPage } from "./templates/DocsPage.tsx"; 6 + import { DocsIndexPage } from "./templates/DocsIndexPage.tsx"; 7 + 8 + // List of available docs 9 + const AVAILABLE_DOCS = [ 10 + { slug: "getting-started", title: "Getting Started", description: "Learn how to set up and use Slices" }, 11 + { slug: "concepts", title: "Core Concepts", description: "Understand slices, lexicons, and collections" }, 12 + { slug: "api-reference", title: "API Reference", description: "Complete endpoint documentation" }, 13 + { slug: "sdk-usage", title: "SDK Usage", description: "Advanced client patterns and examples" }, 14 + ]; 15 + 16 + const DOCS_PATH = Deno.env.get("DOCS_PATH") || "../docs"; 17 + 18 + async function readMarkdownFile(slug: string): Promise<string | null> { 19 + try { 20 + const filePath = `${DOCS_PATH}/${slug}.md`; 21 + const content = await Deno.readTextFile(filePath); 22 + return content; 23 + } catch (error) { 24 + console.error(`Failed to read ${slug}.md:`, error); 25 + return null; 26 + } 27 + } 28 + 29 + // Markdown to HTML converter with Shiki syntax highlighting 30 + async function markdownToHtml(markdown: string): Promise<string> { 31 + // First, extract and process code blocks with Shiki 32 + const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; 33 + const codeBlocks: { placeholder: string; replacement: string }[] = []; 34 + 35 + let html = markdown; 36 + let blockIndex = 0; 37 + let match; 38 + while ((match = codeBlockRegex.exec(markdown)) !== null) { 39 + const [fullMatch, lang, code] = match; 40 + const placeholder = `__CODE_BLOCK_${blockIndex}__`; 41 + 42 + try { 43 + const highlightedCode = await codeToHtml(code.trim(), { 44 + lang: lang || "text", 45 + theme: "tokyo-night", 46 + }); 47 + 48 + // Wrap in a container with proper styling 49 + const styledCode = `<div class="my-4 [&_pre]:p-4 [&_pre]:rounded-md [&_pre]:overflow-x-auto [&_pre]:text-sm">${highlightedCode}</div>`; 50 + 51 + codeBlocks.push({ 52 + placeholder, 53 + replacement: styledCode, 54 + }); 55 + } catch (error) { 56 + // Fallback to simple code block if Shiki fails 57 + console.warn("Shiki highlighting failed:", error); 58 + const fallback = `<pre class="bg-zinc-100 border border-zinc-200 rounded-md p-4 overflow-x-auto my-4"><code class="text-sm">${code.trim()}</code></pre>`; 59 + codeBlocks.push({ 60 + placeholder, 61 + replacement: fallback, 62 + }); 63 + } 64 + 65 + // Replace the code block with placeholder 66 + html = html.replace(fullMatch, placeholder); 67 + blockIndex++; 68 + } 69 + 70 + // Process other markdown elements 71 + html = html 72 + // Headers with inline code (process these first to handle backticks in headers) 73 + .replace(/^#### `([^`]+)`$/gm, '<h4 class="text-base font-semibold text-zinc-900 mt-6 mb-3"><code class="bg-zinc-100 px-2 py-1 rounded text-sm font-mono font-normal">$1</code></h4>') 74 + .replace(/^### `([^`]+)`$/gm, '<h3 class="text-lg font-semibold text-zinc-900 mt-8 mb-4"><code class="bg-zinc-100 px-2 py-1 rounded font-mono font-normal">$1</code></h3>') 75 + .replace(/^## `([^`]+)`$/gm, '<h2 class="text-xl font-bold text-zinc-900 mt-10 mb-4"><code class="bg-zinc-100 px-2 py-1 rounded font-mono font-normal">$1</code></h2>') 76 + // Regular headers (without backticks) 77 + .replace(/^#### (.*$)/gm, '<h4 class="text-base font-semibold text-zinc-900 mt-6 mb-3">$1</h4>') 78 + .replace(/^### (.*$)/gm, '<h3 class="text-lg font-semibold text-zinc-900 mt-8 mb-4">$1</h3>') 79 + .replace(/^## (.*$)/gm, '<h2 class="text-xl font-bold text-zinc-900 mt-10 mb-4">$1</h2>') 80 + .replace(/^# (.*$)/gm, '<h1 class="text-2xl font-bold text-zinc-900 mt-10 mb-6">$1</h1>') 81 + // Inline code (for non-header text) 82 + .replace(/`([^`]+)`/g, '<code class="bg-zinc-100 px-1.5 py-0.5 rounded text-sm font-mono font-normal">$1</code>') 83 + // Bold 84 + .replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>') 85 + // Lists (handle both - and * syntax, process before italic to avoid conflicts) 86 + .replace(/^[\-\*] (.*$)/gm, '<li class="mb-1" data-type="unordered">$1</li>') 87 + // Numbered lists 88 + .replace(/^\d+\. (.*$)/gm, '<li class="mb-1" data-type="ordered">$1</li>') 89 + // Italic (use word boundaries to avoid matching list items) 90 + .replace(/(?<!\*)\*(?!\*)([^\*]+)\*(?!\*)/g, '<em class="italic">$1</em>') 91 + // Links (convert .md links to docs routes) 92 + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { 93 + // Convert relative .md links to docs routes 94 + if (url.endsWith('.md') && !url.startsWith('http')) { 95 + const slug = url.replace(/^\.\//, '').replace(/\.md$/, ''); 96 + return `<a href="/docs/${slug}" class="text-blue-600 hover:text-blue-800 underline">${text}</a>`; 97 + } 98 + return `<a href="${url}" class="text-blue-600 hover:text-blue-800 underline">${text}</a>`; 99 + }); 100 + 101 + // Group consecutive list items into ul/ol elements 102 + html = html.replace(/(<li[^>]*data-type="unordered"[^>]*>.*?<\/li>\s*)+/gs, (match) => { 103 + const cleanedMatch = match.replace(/data-type="unordered"/g, ''); 104 + return `<ul class="list-disc list-inside my-4">${cleanedMatch}</ul>`; 105 + }); 106 + 107 + html = html.replace(/(<li[^>]*data-type="ordered"[^>]*>.*?<\/li>\s*)+/gs, (match) => { 108 + const cleanedMatch = match.replace(/data-type="ordered"/g, ''); 109 + return `<ol class="list-decimal list-inside my-4">${cleanedMatch}</ol>`; 110 + }); 111 + 112 + // Process paragraphs 113 + html = html.split('\n\n') 114 + .map(paragraph => { 115 + const trimmed = paragraph.trim(); 116 + if (!trimmed) return ''; 117 + if (trimmed.startsWith('<') || trimmed.startsWith('__CODE_BLOCK_')) return trimmed; // Already HTML or placeholder 118 + return `<p class="mb-4 leading-relaxed">${trimmed}</p>`; 119 + }) 120 + .join('\n'); 121 + 122 + // Finally, restore code blocks from placeholders 123 + for (const { placeholder, replacement } of codeBlocks) { 124 + html = html.replace(placeholder, replacement); 125 + } 126 + 127 + return html; 128 + } 129 + 130 + async function handleDocsIndex(request: Request): Promise<Response> { 131 + const { currentUser } = await withAuth(request); 132 + return renderHTML( 133 + <DocsIndexPage 134 + docs={AVAILABLE_DOCS} 135 + currentUser={currentUser} 136 + /> 137 + ); 138 + } 139 + 140 + async function handleDocsPage(request: Request): Promise<Response> { 141 + const { currentUser } = await withAuth(request); 142 + const url = new URL(request.url); 143 + const slug = url.pathname.split('/')[2]; // /docs/{slug} 144 + 145 + if (!slug) { 146 + // Redirect to docs index 147 + return new Response(null, { 148 + status: 302, 149 + headers: { Location: '/docs' } 150 + }); 151 + } 152 + 153 + // Check if slug is valid 154 + const docInfo = AVAILABLE_DOCS.find(doc => doc.slug === slug); 155 + if (!docInfo) { 156 + return new Response('Documentation page not found', { status: 404 }); 157 + } 158 + 159 + // Read markdown content 160 + const markdownContent = await readMarkdownFile(slug); 161 + if (!markdownContent) { 162 + return new Response('Documentation content not found', { status: 404 }); 163 + } 164 + 165 + // Convert to HTML with Shiki syntax highlighting 166 + const htmlContent = await markdownToHtml(markdownContent); 167 + 168 + return renderHTML( 169 + <DocsPage 170 + title={docInfo.title} 171 + content={htmlContent} 172 + docs={AVAILABLE_DOCS} 173 + currentSlug={slug} 174 + currentUser={currentUser} 175 + /> 176 + ); 177 + } 178 + 179 + // ============================================================================ 180 + // ROUTE EXPORTS 181 + // ============================================================================ 182 + 183 + export const docsRoutes: Route[] = [ 184 + // Docs index page 185 + { 186 + method: "GET", 187 + pattern: new URLPattern({ pathname: "/docs" }), 188 + handler: handleDocsIndex, 189 + }, 190 + // Individual docs pages 191 + { 192 + method: "GET", 193 + pattern: new URLPattern({ pathname: "/docs/:slug" }), 194 + handler: handleDocsPage, 195 + }, 196 + ];
+47
frontend/src/features/docs/templates/DocsIndexPage.tsx
··· 1 + import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 2 + import { Layout } from "../../../shared/fragments/Layout.tsx"; 3 + 4 + interface DocItem { 5 + slug: string; 6 + title: string; 7 + description: string; 8 + } 9 + 10 + interface DocsIndexPageProps { 11 + docs: DocItem[]; 12 + currentUser?: AuthenticatedUser; 13 + } 14 + 15 + export function DocsIndexPage({ docs, currentUser }: DocsIndexPageProps) { 16 + return ( 17 + <Layout title="Documentation - Slices" currentUser={currentUser}> 18 + <div className="max-w-4xl mx-auto py-8 px-4"> 19 + <div className="mb-8"> 20 + <h1 className="text-3xl font-bold text-zinc-900 mb-2"> 21 + Documentation 22 + </h1> 23 + <p className="text-zinc-600"> 24 + Learn how to build AT Protocol applications with Slices 25 + </p> 26 + </div> 27 + 28 + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> 29 + {docs.map((doc) => ( 30 + <a 31 + key={doc.slug} 32 + href={`/docs/${doc.slug}`} 33 + className="block p-6 bg-white border border-zinc-200 rounded-lg hover:border-zinc-300 hover:shadow-sm transition-all" 34 + > 35 + <h2 className="text-xl font-semibold text-zinc-900 mb-2"> 36 + {doc.title} 37 + </h2> 38 + <p className="text-zinc-600"> 39 + {doc.description} 40 + </p> 41 + </a> 42 + ))} 43 + </div> 44 + </div> 45 + </Layout> 46 + ); 47 + }
+80
frontend/src/features/docs/templates/DocsPage.tsx
··· 1 + import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 2 + import { Layout } from "../../../shared/fragments/Layout.tsx"; 3 + 4 + interface DocItem { 5 + slug: string; 6 + title: string; 7 + description: string; 8 + } 9 + 10 + interface DocsPageProps { 11 + title: string; 12 + content: string; 13 + docs: DocItem[]; 14 + currentSlug: string; 15 + currentUser?: AuthenticatedUser; 16 + } 17 + 18 + export function DocsPage({ title, content, docs, currentSlug, currentUser }: DocsPageProps) { 19 + return ( 20 + <Layout title={`${title} - Slices`} currentUser={currentUser}> 21 + <div className="max-w-6xl mx-auto py-4 sm:py-8 px-4"> 22 + {/* Mobile navigation dropdown */} 23 + <div className="sm:hidden mb-6"> 24 + <label htmlFor="docs-nav" className="block text-sm font-medium text-zinc-700 mb-2"> 25 + Navigate to 26 + </label> 27 + <select 28 + id="docs-nav" 29 + className="block w-full px-3 py-2 text-base border border-zinc-300 bg-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500" 30 + value={currentSlug} 31 + _="on change set window.location to `/docs/${me.value}`" 32 + > 33 + {docs.map((doc) => ( 34 + <option key={doc.slug} value={doc.slug} selected={doc.slug === currentSlug}> 35 + {doc.title} 36 + </option> 37 + ))} 38 + </select> 39 + </div> 40 + 41 + <div className="flex gap-8"> 42 + {/* Desktop Sidebar */} 43 + <nav className="hidden sm:block w-64 flex-shrink-0"> 44 + <div className="sticky sm:top-[5rem]"> 45 + <h2 className="text-sm font-semibold text-zinc-900 mb-4"> 46 + Documentation 47 + </h2> 48 + <ul className="space-y-1"> 49 + {docs.map((doc) => ( 50 + <li key={doc.slug}> 51 + <a 52 + href={`/docs/${doc.slug}`} 53 + className={`block px-3 py-2 text-sm rounded-md transition-colors ${ 54 + doc.slug === currentSlug 55 + ? "bg-zinc-100 text-zinc-900 font-medium" 56 + : "text-zinc-600 hover:text-zinc-900 hover:bg-zinc-50" 57 + }`} 58 + > 59 + {doc.title} 60 + </a> 61 + </li> 62 + ))} 63 + </ul> 64 + </div> 65 + </nav> 66 + 67 + {/* Content */} 68 + <main className="flex-1 min-w-0 overflow-x-hidden"> 69 + <article className="prose prose-zinc max-w-none prose-sm sm:prose-base"> 70 + <div 71 + className="docs-content [&_pre]:overflow-x-auto [&_pre]:max-w-full" 72 + dangerouslySetInnerHTML={{ __html: content }} 73 + /> 74 + </article> 75 + </main> 76 + </div> 77 + </div> 78 + </Layout> 79 + ); 80 + }
+8 -4
frontend/src/routes/mod.ts
··· 4 4 import { dashboardRoutes } from "../features/dashboard/handlers.tsx"; 5 5 import { overviewRoutes, settingsRoutes as sliceSettingsRoutes, lexiconRoutes, recordsRoutes, codegenRoutes, oauthRoutes, apiDocsRoutes, syncRoutes, syncLogsRoutes, jetstreamRoutes } from "../features/slices/mod.ts"; 6 6 import { settingsRoutes } from "../features/settings/handlers.tsx"; 7 + import { docsRoutes } from "../features/docs/handlers.tsx"; 7 8 8 9 export const allRoutes: Route[] = [ 9 10 // Landing page (public, no auth required) 10 11 ...landingRoutes, 11 - 12 + 12 13 // Auth routes (login, oauth, logout) 13 14 ...authRoutes, 14 - 15 + 16 + // Documentation routes 17 + ...docsRoutes, 18 + 15 19 // Dashboard routes (home page, create slice) 16 20 ...dashboardRoutes, 17 - 21 + 18 22 // User settings routes 19 23 ...settingsRoutes, 20 - 24 + 21 25 // Slice-specific routes 22 26 ...overviewRoutes, 23 27 ...sliceSettingsRoutes,
+6
frontend/src/shared/fragments/Layout.tsx
··· 68 68 <a href="/" className="text-xl font-bold text-zinc-900 hover:text-zinc-700"> 69 69 Slices 70 70 </a> 71 + <a 72 + href="/docs" 73 + className="px-3 py-1.5 text-sm text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded-md transition-colors" 74 + > 75 + Docs 76 + </a> 71 77 </div> 72 78 <div className="flex space-x-2"> 73 79 {currentUser?.isAuthenticated ? (