Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

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

wire up lexicon import to /frontend

Chad Miller 0973d15f f0ca27ba

+667 -82
+80 -11
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-08-21 16:58:09 UTC 3 - // Lexicons: 1 2 + // Generated at: 2025-08-21 22:15:58 UTC 3 + // Lexicons: 2 4 4 5 5 export interface OAuthAuthorizeParams { 6 6 loginHint: string; ··· 71 71 export interface CollectionOperations<T> { 72 72 listRecords(params?: ListRecordsParams): Promise<ListRecordsResponse<T>>; 73 73 getRecord(params: GetRecordParams): Promise<RecordResponse<T>>; 74 + } 75 + 76 + export interface XyzSliceatLexiconRecord { 77 + /** When the lexicon was created */ 78 + createdAt: string; 79 + /** The lexicon schema definitions as JSON */ 80 + definitions: string; 81 + /** Namespaced identifier for the lexicon */ 82 + nsid: string; 83 + /** AT-URI reference to the slice this lexicon belongs to */ 84 + slice: string; 85 + /** When the lexicon was last updated */ 86 + updatedAt?: string; 74 87 } 75 88 76 89 export interface XyzSliceatSliceRecord { ··· 421 434 } 422 435 } 423 436 437 + class LexiconSliceatXyzClient extends BaseClient { 438 + constructor( 439 + baseUrl: string, 440 + authBaseUrl: string, 441 + clientId: string, 442 + clientSecret: string 443 + ) { 444 + super(baseUrl, authBaseUrl, clientId, clientSecret); 445 + } 446 + 447 + async listRecords( 448 + params?: ListRecordsParams 449 + ): Promise<ListRecordsResponse<XyzSliceatLexiconRecord>> { 450 + return await this.makeRequest("xyz.sliceat.lexicon.list", "GET", params); 451 + } 452 + 453 + async getRecord( 454 + params: GetRecordParams 455 + ): Promise<RecordResponse<XyzSliceatLexiconRecord>> { 456 + return await this.makeRequest("xyz.sliceat.lexicon.get", "GET", params); 457 + } 458 + 459 + async createRecord( 460 + record: XyzSliceatLexiconRecord 461 + ): Promise<{ uri: string; cid: string }> { 462 + const recordWithType = { $type: "xyz.sliceat.lexicon", ...record }; 463 + return await this.makeRequest( 464 + "xyz.sliceat.lexicon.create", 465 + "POST", 466 + recordWithType 467 + ); 468 + } 469 + 470 + async updateRecord( 471 + rkey: string, 472 + record: XyzSliceatLexiconRecord 473 + ): Promise<{ uri: string; cid: string }> { 474 + const recordWithType = { $type: "xyz.sliceat.lexicon", ...record }; 475 + return await this.makeRequest("xyz.sliceat.lexicon.update", "POST", { 476 + rkey, 477 + record: recordWithType, 478 + }); 479 + } 480 + 481 + async deleteRecord(rkey: string): Promise<void> { 482 + return await this.makeRequest("xyz.sliceat.lexicon.delete", "POST", { 483 + rkey, 484 + }); 485 + } 486 + } 487 + 424 488 class SliceSliceatXyzClient extends BaseClient { 425 489 constructor( 426 490 baseUrl: string, ··· 446 510 async createRecord( 447 511 record: XyzSliceatSliceRecord 448 512 ): Promise<{ uri: string; cid: string }> { 449 - const recordWithType = { 450 - $type: "xyz.sliceat.slice", 451 - ...record, 452 - }; 453 - return await this.makeRequest("xyz.sliceat.slice.create", "POST", recordWithType); 513 + const recordWithType = { $type: "xyz.sliceat.slice", ...record }; 514 + return await this.makeRequest( 515 + "xyz.sliceat.slice.create", 516 + "POST", 517 + recordWithType 518 + ); 454 519 } 455 520 456 521 async updateRecord( 457 522 rkey: string, 458 523 record: XyzSliceatSliceRecord 459 524 ): Promise<{ uri: string; cid: string }> { 460 - const recordWithType = { 461 - $type: "xyz.sliceat.slice", 462 - ...record, 463 - }; 525 + const recordWithType = { $type: "xyz.sliceat.slice", ...record }; 464 526 return await this.makeRequest("xyz.sliceat.slice.update", "POST", { 465 527 rkey, 466 528 record: recordWithType, ··· 473 535 } 474 536 475 537 class SliceatXyzClient extends BaseClient { 538 + readonly lexicon: LexiconSliceatXyzClient; 476 539 readonly slice: SliceSliceatXyzClient; 477 540 478 541 constructor( ··· 482 545 clientSecret: string 483 546 ) { 484 547 super(baseUrl, authBaseUrl, clientId, clientSecret); 548 + this.lexicon = new LexiconSliceatXyzClient( 549 + baseUrl, 550 + authBaseUrl, 551 + clientId, 552 + clientSecret 553 + ); 485 554 this.slice = new SliceSliceatXyzClient( 486 555 baseUrl, 487 556 authBaseUrl,
+37
frontend/src/components/EmptyLexiconState.tsx
··· 1 + interface EmptyLexiconStateProps { 2 + withPadding?: boolean; 3 + } 4 + 5 + export function EmptyLexiconState({ withPadding = false }: EmptyLexiconStateProps) { 6 + const content = ( 7 + <div className="bg-gray-50 rounded-lg p-6 text-center"> 8 + <div className="text-gray-400 mb-4"> 9 + <svg 10 + className="mx-auto h-16 w-16" 11 + fill="none" 12 + viewBox="0 0 24 24" 13 + stroke="currentColor" 14 + > 15 + <path 16 + strokeLinecap="round" 17 + strokeLinejoin="round" 18 + strokeWidth={1} 19 + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 20 + /> 21 + </svg> 22 + </div> 23 + <h3 className="text-lg font-medium text-gray-900 mb-2"> 24 + No lexicons uploaded 25 + </h3> 26 + <p className="text-gray-500"> 27 + Upload lexicon definitions to define custom schemas for this slice. 28 + </p> 29 + </div> 30 + ); 31 + 32 + if (withPadding) { 33 + return <div className="p-6">{content}</div>; 34 + } 35 + 36 + return content; 37 + }
+27
frontend/src/components/LexiconErrorMessage.tsx
··· 1 + export function LexiconErrorMessage({ error }: { error: string }) { 2 + return ( 3 + <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 4 + <div className="flex"> 5 + <div className="flex-shrink-0"> 6 + <svg 7 + className="h-5 w-5 text-red-400" 8 + fill="currentColor" 9 + viewBox="0 0 20 20" 10 + > 11 + <path 12 + fillRule="evenodd" 13 + d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" 14 + clipRule="evenodd" 15 + /> 16 + </svg> 17 + </div> 18 + <div className="ml-3"> 19 + <h3 className="text-sm font-medium">Error creating lexicon</h3> 20 + <div className="mt-2 text-sm"> 21 + <p>{error}</p> 22 + </div> 23 + </div> 24 + </div> 25 + </div> 26 + ); 27 + }
+60
frontend/src/components/LexiconListItem.tsx
··· 1 + import { getRkeyFromUri } from "../utils/at-uri.ts"; 2 + 3 + export function LexiconListItem({ 4 + nsid, 5 + uri, 6 + createdAt, 7 + }: { 8 + nsid: string; 9 + uri: string; 10 + createdAt: string; 11 + }) { 12 + const rkey = getRkeyFromUri(uri); 13 + 14 + return ( 15 + <div 16 + className="border-b border-gray-200 py-4 last:border-b-0" 17 + id={`lexicon-${rkey}`} 18 + > 19 + <div className="flex items-center justify-between"> 20 + <div className="flex-1"> 21 + <h4 className="text-lg font-medium text-gray-900 font-mono"> 22 + {nsid} 23 + </h4> 24 + <p className="text-sm text-gray-500"> 25 + Created: {new Date(createdAt).toLocaleDateString()} 26 + </p> 27 + <p className="text-xs text-gray-400 font-mono">{uri}</p> 28 + </div> 29 + <div className="flex items-center space-x-2"> 30 + <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> 31 + Active 32 + </span> 33 + <button 34 + type="button" 35 + hx-delete={`/api/lexicons/${rkey}`} 36 + hx-target={`#lexicon-${rkey}`} 37 + hx-swap="outerHTML" 38 + hx-confirm="Are you sure you want to delete this lexicon?" 39 + className="inline-flex items-center px-2 py-1 border border-red-300 rounded text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" 40 + > 41 + <svg 42 + className="h-3 w-3 mr-1" 43 + fill="none" 44 + viewBox="0 0 24 24" 45 + stroke="currentColor" 46 + > 47 + <path 48 + strokeLinecap="round" 49 + strokeLinejoin="round" 50 + strokeWidth={2} 51 + d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" 52 + /> 53 + </svg> 54 + Delete 55 + </button> 56 + </div> 57 + </div> 58 + </div> 59 + ); 60 + }
+40
frontend/src/components/LexiconSuccessMessage.tsx
··· 1 + export function LexiconSuccessMessage({ nsid, uri }: { nsid: string; uri: string }) { 2 + return ( 3 + <div 4 + className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" 5 + hx-trigger="load" 6 + hx-get="/api/lexicons/list" 7 + hx-target="#lexicon-list" 8 + > 9 + <div className="flex"> 10 + <div className="flex-shrink-0"> 11 + <svg 12 + className="h-5 w-5 text-green-400" 13 + fill="currentColor" 14 + viewBox="0 0 20 20" 15 + > 16 + <path 17 + fillRule="evenodd" 18 + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" 19 + clipRule="evenodd" 20 + /> 21 + </svg> 22 + </div> 23 + <div className="ml-3"> 24 + <h3 className="text-sm font-medium">Lexicon created successfully!</h3> 25 + <div className="mt-2 text-sm"> 26 + <p> 27 + <strong>NSID:</strong> {nsid} 28 + </p> 29 + <p> 30 + <strong>URI:</strong>{" "} 31 + <code className="bg-green-50 px-2 py-1 rounded text-xs"> 32 + {uri} 33 + </code> 34 + </p> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 39 + ); 40 + }
+76 -26
frontend/src/pages/SliceLexiconPage.tsx
··· 1 1 import { Layout } from "../components/Layout.tsx"; 2 + import { EmptyLexiconState } from "../components/EmptyLexiconState.tsx"; 2 3 3 4 interface SliceLexiconPageProps { 4 5 sliceName?: string; ··· 58 59 59 60 <div className="bg-white rounded-lg shadow-md p-6 mb-6"> 60 61 <h2 className="text-xl font-semibold text-gray-800 mb-4"> 61 - Upload Lexicon Definitions 62 + Add Lexicon Definition 62 63 </h2> 63 64 <p className="text-gray-600 mb-6"> 64 - Upload lexicon schema files to define custom record types for this slice. 65 + Paste lexicon JSON to define custom record types for this slice. 66 + </p> 67 + 68 + <form 69 + hx-post="/api/lexicons" 70 + hx-target="#lexicon-result" 71 + hx-swap="innerHTML" 72 + hx-on="htmx:afterRequest: if(event.detail.successful) this.reset()" 73 + className="space-y-4" 74 + > 75 + <div> 76 + <label className="block text-sm font-medium text-gray-700 mb-2"> 77 + Lexicon JSON 78 + </label> 79 + <textarea 80 + name="lexicon_json" 81 + rows={12} 82 + className="block w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm" 83 + placeholder={`{ 84 + "lexicon": 1, 85 + "id": "xyz.sliceat.example", 86 + "description": "Example record type", 87 + "defs": { 88 + "main": { 89 + "type": "record", 90 + "key": "tid", 91 + "record": { 92 + "type": "object", 93 + "required": ["text", "createdAt"], 94 + "properties": { 95 + "text": { 96 + "type": "string", 97 + "maxLength": 300 98 + }, 99 + "createdAt": { 100 + "type": "string", 101 + "format": "datetime" 102 + } 103 + } 104 + } 105 + } 106 + } 107 + }`} 108 + required 109 + /> 110 + <p className="text-sm text-gray-500 mt-1"> 111 + Paste a valid AT Protocol lexicon definition in JSON format 112 + </p> 113 + </div> 114 + 115 + <button 116 + type="submit" 117 + className="bg-purple-500 hover:bg-purple-600 text-white px-6 py-2 rounded-md" 118 + > 119 + Add Lexicon 120 + </button> 121 + </form> 122 + 123 + <div id="lexicon-result" className="mt-4"></div> 124 + </div> 125 + 126 + <div className="bg-white rounded-lg shadow-md p-6 mb-6"> 127 + <h2 className="text-xl font-semibold text-gray-800 mb-4"> 128 + Upload Lexicon Files 129 + </h2> 130 + <p className="text-gray-600 mb-6"> 131 + Or upload lexicon schema files to define custom record types for this slice. 65 132 </p> 66 133 67 134 <form ··· 100 167 Slice Lexicons 101 168 </h2> 102 169 </div> 103 - <div className="p-6"> 104 - <div className="bg-gray-50 rounded-lg p-6 text-center"> 105 - <div className="text-gray-400 mb-4"> 106 - <svg 107 - className="mx-auto h-16 w-16" 108 - fill="none" 109 - viewBox="0 0 24 24" 110 - stroke="currentColor" 111 - > 112 - <path 113 - strokeLinecap="round" 114 - strokeLinejoin="round" 115 - strokeWidth={1} 116 - d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 117 - /> 118 - </svg> 119 - </div> 120 - <h3 className="text-lg font-medium text-gray-900 mb-2"> 121 - No lexicons uploaded 122 - </h3> 123 - <p className="text-gray-500"> 124 - Upload lexicon definitions to define custom schemas for this slice. 125 - </p> 126 - </div> 170 + <div 171 + id="lexicon-list" 172 + className="p-6" 173 + hx-get="/api/lexicons/list" 174 + hx-trigger="load" 175 + > 176 + <EmptyLexiconState /> 127 177 </div> 128 178 </div> 129 179
-24
frontend/src/pages/SlicePage.tsx
··· 189 189 )} 190 190 </div> 191 191 192 - <div className="mt-12 bg-white rounded-lg shadow-md p-6"> 193 - <h2 className="text-2xl font-semibold text-gray-800 mb-4"> 194 - API Endpoints 195 - </h2> 196 - <div className="space-y-4"> 197 - <div> 198 - <code className="bg-gray-100 px-2 py-1 rounded"> 199 - GET /xrpc/com.indexer.records.list?collection=app.bsky.feed.post 200 - </code> 201 - <p className="text-gray-600 mt-1"> 202 - List records for a collection 203 - </p> 204 - </div> 205 - <div> 206 - <code className="bg-gray-100 px-2 py-1 rounded"> 207 - POST /xrpc/com.indexer.collections.bulkSync 208 - </code> 209 - <p className="text-gray-600 mt-1"> 210 - Bulk sync collections (JSON:{" "} 211 - {`{"collections": ["app.bsky.feed.post"]}`}) 212 - </p> 213 - </div> 214 - </div> 215 - </div> 216 192 </div> 217 193 </Layout> 218 194 );
+14 -7
frontend/src/routes/pages.tsx
··· 19 19 20 20 if (context.currentUser.isAuthenticated) { 21 21 try { 22 - const sliceRecords = 23 - await atprotoClient.xyz.sliceat.slice.listRecords(); 22 + const sliceRecords = await atprotoClient.xyz.sliceat.slice.listRecords(); 24 23 25 24 slices = sliceRecords.records.map((record) => { 26 25 // Extract slice ID from URI ··· 39 38 } 40 39 } 41 40 42 - const html = render(<IndexPage slices={slices} currentUser={context.currentUser} />); 41 + const html = render( 42 + <IndexPage slices={slices} currentUser={context.currentUser} /> 43 + ); 43 44 44 45 const responseHeaders: Record<string, string> = { 45 46 "content-type": "text/html", ··· 59 60 async function handleLoginPage(req: Request): Promise<Response> { 60 61 const context = await withAuth(req); 61 62 const url = new URL(req.url); 62 - 63 + 63 64 // Login page with optional error message 64 65 const error = url.searchParams.get("error"); 65 66 const html = render( ··· 80 81 }); 81 82 } 82 83 83 - async function handleSlicePage(req: Request, params: any): Promise<Response> { 84 + async function handleSlicePage( 85 + req: Request, 86 + params?: URLPatternResult 87 + ): Promise<Response> { 84 88 const context = await withAuth(req); 85 89 const sliceId = params?.pathname.groups.id; 86 90 ··· 140 144 }); 141 145 } 142 146 143 - async function handleSliceTabPage(req: Request, params: any): Promise<Response> { 147 + async function handleSliceTabPage( 148 + req: Request, 149 + params?: URLPatternResult 150 + ): Promise<Response> { 144 151 const context = await withAuth(req); 145 152 const sliceId = params?.pathname.groups.id; 146 153 const tab = params?.pathname.groups.tab; ··· 286 293 pattern: new URLPattern({ pathname: "/slices/:id/:tab" }), 287 294 handler: handleSliceTabPage, 288 295 }, 289 - ]; 296 + ];
+215 -14
frontend/src/routes/slices.tsx
··· 4 4 import { atprotoClient } from "../config.ts"; 5 5 import { CreateSliceDialog } from "../components/CreateSliceDialog.tsx"; 6 6 import { UpdateResult } from "../components/UpdateResult.tsx"; 7 + import { EmptyLexiconState } from "../components/EmptyLexiconState.tsx"; 8 + import { LexiconSuccessMessage } from "../components/LexiconSuccessMessage.tsx"; 9 + import { LexiconErrorMessage } from "../components/LexiconErrorMessage.tsx"; 10 + import { LexiconListItem } from "../components/LexiconListItem.tsx"; 7 11 8 12 async function handleCreateSlice(req: Request): Promise<Response> { 9 13 const context = await withAuth(req); ··· 76 80 } 77 81 } 78 82 79 - async function handleUpdateSliceName(req: Request, params: any): Promise<Response> { 83 + async function handleUpdateSliceName( 84 + req: Request, 85 + params?: URLPatternResult 86 + ): Promise<Response> { 80 87 const context = await withAuth(req); 81 88 const authResponse = requireAuth(context, req); 82 89 if (authResponse) return authResponse; ··· 102 109 103 110 // Construct the URI for this slice 104 111 const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/${sliceId}`; 105 - 112 + 106 113 // Get the current record first 107 114 const currentRecord = await atprotoClient.xyz.sliceat.slice.getRecord({ 108 115 uri: sliceUri, ··· 114 121 name: name.trim(), 115 122 }; 116 123 117 - await atprotoClient.xyz.sliceat.slice.updateRecord( 118 - sliceId, 119 - updatedRecord 120 - ); 124 + await atprotoClient.xyz.sliceat.slice.updateRecord(sliceId, updatedRecord); 121 125 122 126 const resultHtml = render( 123 - <UpdateResult 124 - type="success" 125 - message="Slice name updated successfully!" 127 + <UpdateResult 128 + type="success" 129 + message="Slice name updated successfully!" 126 130 showRefresh 127 131 /> 128 132 ); ··· 132 136 }); 133 137 } catch (error) { 134 138 const resultHtml = render( 135 - <UpdateResult 136 - type="error" 137 - message="Failed to update slice name. Please try again." 139 + <UpdateResult 140 + type="error" 141 + message="Failed to update slice name. Please try again." 138 142 /> 139 143 ); 140 144 return new Response(resultHtml, { ··· 144 148 } 145 149 } 146 150 147 - async function handleDeleteSlice(req: Request, params: any): Promise<Response> { 151 + async function handleDeleteSlice(req: Request, params?: URLPatternResult): Promise<Response> { 148 152 const context = await withAuth(req); 149 153 const authResponse = requireAuth(context, req); 150 154 if (authResponse) return authResponse; ··· 170 174 } 171 175 } 172 176 177 + async function handleListLexicons(req: Request): Promise<Response> { 178 + const context = await withAuth(req); 179 + const authResponse = requireAuth(context, req); 180 + if (authResponse) return authResponse; 181 + 182 + try { 183 + // Fetch lexicons from AT Protocol 184 + const lexiconRecords = 185 + await atprotoClient.xyz.sliceat.lexicon.listRecords(); 186 + 187 + if (lexiconRecords.records.length === 0) { 188 + const html = render(<EmptyLexiconState />); 189 + return new Response(html, { 190 + status: 200, 191 + headers: { "content-type": "text/html" }, 192 + }); 193 + } 194 + 195 + const html = render( 196 + <div className="space-y-0"> 197 + {lexiconRecords.records.map((record) => ( 198 + <LexiconListItem 199 + key={record.uri} 200 + nsid={record.value.nsid} 201 + uri={record.uri} 202 + createdAt={record.value.createdAt} 203 + /> 204 + ))} 205 + </div> 206 + ); 207 + 208 + return new Response(html, { 209 + status: 200, 210 + headers: { "content-type": "text/html" }, 211 + }); 212 + } catch (error) { 213 + console.error("Failed to fetch lexicons:", error); 214 + const html = render( 215 + <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 216 + <p>Failed to load lexicons: {error}</p> 217 + </div> 218 + ); 219 + return new Response(html, { 220 + status: 500, 221 + headers: { "content-type": "text/html" }, 222 + }); 223 + } 224 + } 225 + 226 + async function handleCreateLexicon(req: Request): Promise<Response> { 227 + const context = await withAuth(req); 228 + const authResponse = requireAuth(context, req); 229 + if (authResponse) return authResponse; 230 + 231 + try { 232 + const formData = await req.formData(); 233 + const lexiconJson = formData.get("lexicon_json") as string; 234 + 235 + if (!lexiconJson || lexiconJson.trim().length === 0) { 236 + const html = render( 237 + <LexiconErrorMessage error="Lexicon JSON is required" /> 238 + ); 239 + return new Response(html, { 240 + status: 400, 241 + headers: { "content-type": "text/html" }, 242 + }); 243 + } 244 + 245 + // Parse the lexicon JSON 246 + let lexiconData; 247 + try { 248 + lexiconData = JSON.parse(lexiconJson); 249 + } catch (parseError) { 250 + const html = render( 251 + <LexiconErrorMessage 252 + error={`Failed to parse lexicon JSON: ${parseError}`} 253 + /> 254 + ); 255 + return new Response(html, { 256 + status: 400, 257 + headers: { "content-type": "text/html" }, 258 + }); 259 + } 260 + 261 + // Create the lexicon record 262 + try { 263 + // For now, we'll create a simple slice reference - this could be improved to reference a specific slice 264 + const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/example`; 265 + 266 + const lexiconRecord = { 267 + nsid: lexiconData.id, 268 + definitions: JSON.stringify(lexiconData.defs || lexiconData), 269 + createdAt: new Date().toISOString(), 270 + slice: sliceUri, 271 + }; 272 + 273 + const result = await atprotoClient.xyz.sliceat.lexicon.createRecord( 274 + lexiconRecord 275 + ); 276 + 277 + const html = render( 278 + <LexiconSuccessMessage nsid={lexiconRecord.nsid} uri={result.uri} /> 279 + ); 280 + return new Response(html, { 281 + status: 200, 282 + headers: { "content-type": "text/html" }, 283 + }); 284 + } catch (createError) { 285 + const html = render( 286 + <LexiconErrorMessage 287 + error={`Failed to create lexicon: ${createError}`} 288 + /> 289 + ); 290 + return new Response(html, { 291 + status: 500, 292 + headers: { "content-type": "text/html" }, 293 + }); 294 + } 295 + } catch (error) { 296 + const html = render( 297 + <LexiconErrorMessage error={`Server error: ${error}`} /> 298 + ); 299 + return new Response(html, { 300 + status: 500, 301 + headers: { "content-type": "text/html" }, 302 + }); 303 + } 304 + } 305 + 306 + async function handleDeleteLexicon( 307 + req: Request, 308 + params?: URLPatternResult 309 + ): Promise<Response> { 310 + const context = await withAuth(req); 311 + const authResponse = requireAuth(context, req); 312 + if (authResponse) return authResponse; 313 + 314 + const rkey = params?.pathname.groups.rkey; 315 + if (!rkey) { 316 + return new Response("Invalid lexicon ID", { status: 400 }); 317 + } 318 + 319 + try { 320 + // Delete the lexicon record from AT Protocol 321 + await atprotoClient.xyz.sliceat.lexicon.deleteRecord(rkey); 322 + 323 + // Check if there are any remaining lexicons 324 + const remainingLexicons = 325 + await atprotoClient.xyz.sliceat.lexicon.listRecords(); 326 + 327 + if (remainingLexicons.records.length === 0) { 328 + // If no lexicons remain, return the empty state and target the parent list 329 + const html = render(<EmptyLexiconState withPadding />); 330 + 331 + return new Response(html, { 332 + status: 200, 333 + headers: { 334 + "content-type": "text/html", 335 + "HX-Retarget": "#lexicon-list", 336 + }, 337 + }); 338 + } else { 339 + // Just remove this specific item 340 + return new Response("", { 341 + status: 200, 342 + headers: { "content-type": "text/html" }, 343 + }); 344 + } 345 + } catch (error) { 346 + console.error("Failed to delete lexicon:", error); 347 + const html = render( 348 + <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 349 + <p>Failed to delete lexicon: {error}</p> 350 + </div> 351 + ); 352 + return new Response(html, { 353 + status: 500, 354 + headers: { "content-type": "text/html" }, 355 + }); 356 + } 357 + } 358 + 173 359 export const sliceRoutes: Route[] = [ 174 360 { 175 361 method: "POST", ··· 186 372 pattern: new URLPattern({ pathname: "/api/slices/:id" }), 187 373 handler: handleDeleteSlice, 188 374 }, 189 - ]; 375 + { 376 + method: "POST", 377 + pattern: new URLPattern({ pathname: "/api/lexicons" }), 378 + handler: handleCreateLexicon, 379 + }, 380 + { 381 + method: "GET", 382 + pattern: new URLPattern({ pathname: "/api/lexicons/list" }), 383 + handler: handleListLexicons, 384 + }, 385 + { 386 + method: "DELETE", 387 + pattern: new URLPattern({ pathname: "/api/lexicons/:rkey" }), 388 + handler: handleDeleteLexicon, 389 + }, 390 + ];
+77
frontend/src/utils/at-uri.ts
··· 1 + /** 2 + * Parsed AT-URI components 3 + */ 4 + export interface AtUri { 5 + protocol: string; 6 + did: string; 7 + collection: string; 8 + rkey: string; 9 + fragment?: string; 10 + } 11 + 12 + /** 13 + * Parse an AT-URI into its components 14 + * Format: at://did:method:identifier/collection/rkey#fragment 15 + */ 16 + export function parseAtUri(uri: string): AtUri { 17 + if (!uri.startsWith('at://')) { 18 + throw new Error(`Invalid AT-URI: must start with at:// - got ${uri}`); 19 + } 20 + 21 + const withoutProtocol = uri.slice(5); // Remove 'at://' 22 + const [didAndPath, fragment] = withoutProtocol.split('#'); 23 + const parts = didAndPath.split('/'); 24 + 25 + if (parts.length < 3) { 26 + throw new Error(`Invalid AT-URI: missing required parts - got ${uri}`); 27 + } 28 + 29 + const [did, collection, rkey, ...rest] = parts; 30 + 31 + if (rest.length > 0) { 32 + throw new Error(`Invalid AT-URI: too many path segments - got ${uri}`); 33 + } 34 + 35 + return { 36 + protocol: 'at', 37 + did, 38 + collection, 39 + rkey, 40 + fragment 41 + }; 42 + } 43 + 44 + /** 45 + * Build an AT-URI from components 46 + */ 47 + export function buildAtUri(components: Omit<AtUri, 'protocol'>): string { 48 + const { did, collection, rkey, fragment } = components; 49 + let uri = `at://${did}/${collection}/${rkey}`; 50 + 51 + if (fragment) { 52 + uri += `#${fragment}`; 53 + } 54 + 55 + return uri; 56 + } 57 + 58 + /** 59 + * Extract just the record key from an AT-URI 60 + */ 61 + export function getRkeyFromUri(uri: string): string { 62 + return parseAtUri(uri).rkey; 63 + } 64 + 65 + /** 66 + * Extract the DID from an AT-URI 67 + */ 68 + export function getDidFromUri(uri: string): string { 69 + return parseAtUri(uri).did; 70 + } 71 + 72 + /** 73 + * Extract the collection from an AT-URI 74 + */ 75 + export function getCollectionFromUri(uri: string): string { 76 + return parseAtUri(uri).collection; 77 + }
+41
lexicons/xyz/sliceat/lexicon.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.sliceat.lexicon", 4 + "description": "Lexicon definition record type for AT Protocol schema storage", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["nsid", "definitions", "createdAt", "slice"], 12 + "properties": { 13 + "nsid": { 14 + "type": "string", 15 + "description": "Namespaced identifier for the lexicon", 16 + "maxLength": 256 17 + }, 18 + "definitions": { 19 + "type": "string", 20 + "description": "The lexicon schema definitions as JSON" 21 + }, 22 + "createdAt": { 23 + "type": "string", 24 + "format": "datetime", 25 + "description": "When the lexicon was created" 26 + }, 27 + "updatedAt": { 28 + "type": "string", 29 + "format": "datetime", 30 + "description": "When the lexicon was last updated" 31 + }, 32 + "slice": { 33 + "type": "string", 34 + "format": "at-uri", 35 + "description": "AT-URI reference to the slice this lexicon belongs to" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }