Highly ambitious ATProtocol AppView service and sdks

rework frontend for unauthenticated access to slices, refector lexicons list ui, other styling updates

Changed files
+2589 -1410
api
src
frontend
src
features
lib
routes
shared
utils
+61 -19
api/src/jobs.rs
··· 431 431 limit: Option<i64>, 432 432 ) -> Result<Vec<JobStatus>, sqlx::Error> { 433 433 let limit = limit.unwrap_or(10); 434 - 434 + 435 435 info!( 436 436 "Querying job history: user_did={}, slice_uri={}, limit={}", 437 437 user_did, slice_uri, limit 438 438 ); 439 - 439 + 440 + // Get both completed jobs and pending jobs 440 441 let rows = sqlx::query!( 441 442 r#" 442 - SELECT 443 + -- Completed jobs from job_results 444 + SELECT 443 445 job_id, user_did, slice_uri, status, success, total_records, 444 446 collections_synced, repos_processed, message, error_message, 445 - created_at, completed_at 446 - FROM job_results 447 + created_at, completed_at, 448 + 'completed' as job_type 449 + FROM job_results 447 450 WHERE user_did = $1 AND slice_uri = $2 451 + 452 + UNION ALL 453 + 454 + -- Pending jobs from message queue 455 + SELECT 456 + (p.payload_json->>'job_id')::uuid as job_id, 457 + p.payload_json->>'user_did' as user_did, 458 + p.payload_json->>'slice_uri' as slice_uri, 459 + 'running' as status, 460 + NULL::boolean as success, 461 + NULL::bigint as total_records, 462 + '[]'::jsonb as collections_synced, 463 + NULL::bigint as repos_processed, 464 + 'Job in progress...' as message, 465 + NULL::text as error_message, 466 + m.created_at, 467 + NULL::timestamptz as completed_at, 468 + 'pending' as job_type 469 + FROM mq_msgs m 470 + JOIN mq_payloads p ON m.id = p.id 471 + WHERE m.channel_name = 'sync_queue' 472 + AND m.id != '00000000-0000-0000-0000-000000000000' 473 + AND p.payload_json->>'user_did' = $1 474 + AND p.payload_json->>'slice_uri' = $2 475 + AND NOT EXISTS ( 476 + SELECT 1 FROM job_results jr 477 + WHERE jr.job_id = (p.payload_json->>'job_id')::uuid 478 + ) 479 + 448 480 ORDER BY created_at DESC 449 481 LIMIT $3 450 482 "#, ··· 457 489 458 490 let mut results = Vec::new(); 459 491 for row in rows { 460 - let collections_synced: Vec<String> = serde_json::from_value(row.collections_synced) 461 - .unwrap_or_default(); 492 + let collections_synced: Vec<String> = serde_json::from_value( 493 + row.collections_synced.unwrap_or_else(|| serde_json::json!([])) 494 + ).unwrap_or_default(); 462 495 463 - results.push(JobStatus { 464 - job_id: row.job_id, 465 - status: row.status, 466 - created_at: row.created_at, 467 - started_at: Some(row.created_at), 468 - completed_at: Some(row.completed_at), 469 - result: Some(SyncJobResult { 470 - success: row.success, 471 - total_records: row.total_records, 496 + // Handle both completed and pending jobs 497 + let result = if row.job_type.as_deref() == Some("pending") || row.success.is_none() { 498 + // This is a pending job - no result data available 499 + None 500 + } else { 501 + // This is a completed job - include result data 502 + Some(SyncJobResult { 503 + success: row.success.unwrap_or(false), 504 + total_records: row.total_records.unwrap_or(0), 472 505 collections_synced, 473 - repos_processed: row.repos_processed, 474 - message: row.message, 475 - }), 506 + repos_processed: row.repos_processed.unwrap_or(0), 507 + message: row.message.clone().unwrap_or_default(), 508 + }) 509 + }; 510 + 511 + results.push(JobStatus { 512 + job_id: row.job_id.unwrap_or_else(Uuid::new_v4), 513 + status: row.status.unwrap_or_default(), 514 + created_at: row.created_at.unwrap_or_else(chrono::Utc::now), 515 + started_at: row.created_at, 516 + completed_at: row.completed_at, 517 + result, 476 518 error: row.error_message, 477 519 retry_count: 0, 478 520 });
+30 -9
frontend/CLAUDE.md
··· 1 1 # CLAUDE.md 2 2 3 - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 3 + This file provides guidance to Claude Code (claude.ai/code) when working with 4 + code in this repository. 4 5 5 6 ## Development Commands 6 7 ··· 20 21 21 22 ## Architecture Overview 22 23 23 - This is a Deno-based web application that serves as the frontend for a "Slices" platform - an AT Protocol record management system. The application follows a feature-based architecture with server-side rendering using Preact and HTMX for interactivity. 24 + This is a Deno-based web application that serves as the frontend for a "Slices" 25 + platform - an AT Protocol record management system. The application follows a 26 + feature-based architecture with server-side rendering using Preact and HTMX for 27 + interactivity. 24 28 25 29 ### Technology Stack 30 + 26 31 - **Runtime**: Deno with TypeScript 27 - - **Frontend**: Preact with server-side rendering 32 + - **Frontend**: Preact with server-side rendering 28 33 - **Styling**: Tailwind CSS (via CDN) 29 34 - **Interactivity**: HTMX + Hyperscript 30 35 - **Routing**: Deno's standard HTTP routing ··· 34 39 ### Core Architecture Patterns 35 40 36 41 #### Feature-Based Organization 42 + 37 43 The codebase is organized by features rather than technical layers: 38 44 39 45 ``` ··· 58 64 ``` 59 65 60 66 #### Handler Pattern 67 + 61 68 Each feature follows a consistent pattern: 69 + 62 70 - `handlers.tsx` - Route handlers that return Response objects 63 71 - `templates/` - Preact components for rendering 64 72 - `templates/fragments/` - Reusable UI components 65 73 66 74 #### Authentication & Sessions 75 + 67 76 - OAuth integration with AT Protocol using `@slices/oauth` 68 - - Session management with `@slices/session` 77 + - Session management with `@slices/session` 69 78 - Authentication state managed via `withAuth()` middleware 70 79 - Automatic token refresh capabilities 71 80 72 81 ### Key Components 73 82 74 83 #### Route System 84 + 75 85 - All routes defined in `src/routes/mod.ts` 76 86 - Feature routes exported from `src/features/*/handlers.tsx` 77 87 - Middleware in `src/routes/middleware.ts` handles auth state 78 88 79 - #### Client Integration 89 + #### Client Integration 90 + 80 91 - `src/client.ts` - Generated AT Protocol client for API communication 81 92 - `src/config.ts` - Centralized configuration and service setup 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") 93 + - Environment variables required: `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, 94 + `OAUTH_REDIRECT_URI`, `OAUTH_AIP_BASE_URL`, `API_URL`, `SLICE_URI` 95 + - Optional variables: `DOCS_PATH` (path to markdown documentation files, 96 + defaults to "../docs") 84 97 85 98 #### Rendering System 99 + 86 100 - `src/utils/render.tsx` - Unified HTML rendering with proper headers 87 101 - Server-side rendering with Preact 88 102 - HTMX for dynamic interactions without page reloads ··· 91 105 ### Development Guidelines 92 106 93 107 #### Component Conventions 108 + 94 109 - Use `.tsx` extension for components with JSX 95 110 - Preact components for all UI rendering 96 111 - HTMX attributes for interactive behavior 97 112 - Tailwind classes for styling 98 113 99 114 #### Feature Development 115 + 100 116 When adding new features: 117 + 101 118 1. Create feature directory under `src/features/` 102 119 2. Add `handlers.tsx` with route definitions 103 120 3. Create `templates/` directory with Preact components ··· 105 122 5. Follow existing authentication patterns using `withAuth()` 106 123 107 124 #### Environment Setup 108 - The application requires a `.env` file with OAuth and API configuration. Missing environment variables will cause startup failures with descriptive error messages. 125 + 126 + The application requires a `.env` file with OAuth and API configuration. Missing 127 + environment variables will cause startup failures with descriptive error 128 + messages. 109 129 110 130 ### Request/Response Flow 131 + 111 132 1. Request hits main server in `src/main.ts` 112 133 2. Routes processed through `src/routes/mod.ts` 113 134 3. Authentication middleware applies session state 114 135 4. Feature handlers process requests and return rendered HTML 115 - 5. HTMX handles partial page updates on client-side interactions 136 + 5. HTMX handles partial page updates on client-side interactions
+24 -25
frontend/src/client.test.ts
··· 19 19 private callCount = 0; 20 20 21 21 constructor( 22 - initialState: "valid" | "expired" | "refresh_fails" | "no_tokens" 22 + initialState: "valid" | "expired" | "refresh_fails" | "no_tokens", 23 23 ) { 24 24 this.tokenState = initialState; 25 25 } ··· 27 27 async ensureValidToken() { 28 28 this.callCount++; 29 29 console.log( 30 - `MockOAuth.ensureValidToken() called (attempt ${this.callCount})` 30 + `MockOAuth.ensureValidToken() called (attempt ${this.callCount})`, 31 31 ); 32 32 33 33 switch (this.tokenState) { ··· 77 77 function createMockFetch() { 78 78 return async ( 79 79 url: string | URL | Request, 80 - init?: RequestInit 80 + init?: RequestInit, 81 81 ): Promise<Response> => { 82 82 const requestUrl = url.toString(); 83 83 const method = init?.method || "GET"; ··· 87 87 console.log(`Authorization header: ${headers["Authorization"] || "none"}`); 88 88 89 89 // Check if request has valid authorization 90 - const hasAuth = 91 - headers["Authorization"]?.startsWith("Bearer valid") || 90 + const hasAuth = headers["Authorization"]?.startsWith("Bearer valid") || 92 91 headers["Authorization"]?.startsWith("Bearer refreshed"); 93 92 94 93 if (method === "GET") { ··· 106 105 { 107 106 status: 200, 108 107 headers: { "Content-Type": "application/json" }, 109 - } 108 + }, 110 109 ); 111 110 } else { 112 111 // POST/PUT/DELETE require valid auth ··· 119 118 { 120 119 status: 200, 121 120 headers: { "Content-Type": "application/json" }, 122 - } 121 + }, 123 122 ); 124 123 } else { 125 124 return new Response( ··· 129 128 { 130 129 status: 401, 131 130 headers: { "Content-Type": "application/json" }, 132 - } 131 + }, 133 132 ); 134 133 } 135 134 } ··· 137 136 } 138 137 139 138 function createTestClient( 140 - tokenState: "valid" | "expired" | "refresh_fails" | "no_tokens" 139 + tokenState: "valid" | "expired" | "refresh_fails" | "no_tokens", 141 140 ) { 142 141 const mockOAuth = new MockOAuthClient(tokenState); 143 142 return new AtProtoClient( 144 143 "https://test-api.example.com", 145 144 "at://did:plc:test/network.slices.slice/test", 146 - mockOAuth as any 145 + mockOAuth as any, 147 146 ); 148 147 } 149 148 ··· 195 194 console.log("Read operation succeeded after token refresh"); 196 195 } catch (error) { 197 196 throw new Error( 198 - `Read operation should have succeeded after refresh: ${error}` 197 + `Read operation should have succeeded after refresh: ${error}`, 199 198 ); 200 199 } finally { 201 200 // Restore original fetch 202 201 globalThis.fetch = originalFetch; 203 202 } 204 - } 203 + }, 205 204 ); 206 205 207 206 Deno.test( ··· 220 219 console.log("Write operation succeeded after token refresh"); 221 220 } catch (error) { 222 221 throw new Error( 223 - `Write operation should have succeeded after refresh: ${error}` 222 + `Write operation should have succeeded after refresh: ${error}`, 224 223 ); 225 224 } finally { 226 225 // Restore original fetch 227 226 globalThis.fetch = originalFetch; 228 227 } 229 - } 228 + }, 230 229 ); 231 230 232 231 Deno.test("Token refresh fails - read operation should succeed", async () => { ··· 239 238 console.log("Read operation succeeded without auth (as expected)"); 240 239 } catch (error) { 241 240 throw new Error( 242 - `Read operation should succeed even without auth: ${error}` 241 + `Read operation should succeed even without auth: ${error}`, 243 242 ); 244 243 } finally { 245 244 // Restore original fetch ··· 265 264 }, 266 265 Error, 267 266 "Authentication required", 268 - "Write operation should fail with authentication error when tokens can't be refreshed" 267 + "Write operation should fail with authentication error when tokens can't be refreshed", 269 268 ); 270 269 } finally { 271 270 // Restore original fetch 272 271 globalThis.fetch = originalFetch; 273 272 } 274 - } 273 + }, 275 274 ); 276 275 277 276 Deno.test("No tokens - read operation should succeed", async () => { ··· 284 283 console.log("Read operation succeeded without tokens (as expected)"); 285 284 } catch (error) { 286 285 throw new Error( 287 - `Read operation should succeed even without tokens: ${error}` 286 + `Read operation should succeed even without tokens: ${error}`, 288 287 ); 289 288 } finally { 290 289 // Restore original fetch ··· 310 309 }, 311 310 Error, 312 311 "Authentication required", 313 - "Write operation should fail with authentication error when no tokens available" 312 + "Write operation should fail with authentication error when no tokens available", 314 313 ); 315 314 } finally { 316 315 // Restore original fetch 317 316 globalThis.fetch = originalFetch; 318 317 } 319 - } 318 + }, 320 319 ); 321 320 322 321 Deno.test("401 response triggers token refresh and retry", async () => { ··· 324 323 let callCount = 0; 325 324 const mockFetch = ( 326 325 url: string | URL | Request, 327 - init?: RequestInit 326 + init?: RequestInit, 328 327 ): Promise<Response> => { 329 328 callCount++; 330 329 const requestUrl = url.toString(); ··· 342 341 new Response(JSON.stringify({ error: "Unauthorized" }), { 343 342 status: 401, 344 343 headers: { "Content-Type": "application/json" }, 345 - }) 344 + }), 346 345 ); 347 346 } else { 348 347 // Second call should succeed (with refreshed token) ··· 356 355 { 357 356 status: 200, 358 357 headers: { "Content-Type": "application/json" }, 359 - } 360 - ) 358 + }, 359 + ), 361 360 ); 362 361 } 363 362 } ··· 382 381 } 383 382 384 383 console.log( 385 - "401 retry test passed - request was retried after token refresh" 384 + "401 retry test passed - request was retried after token refresh", 386 385 ); 387 386 } finally { 388 387 // Restore original fetch
+152 -111
frontend/src/client.ts
··· 255 255 labels?: 256 256 | ComAtprotoLabelDefs["SelfLabels"] 257 257 | { 258 - $type: string; 259 - [key: string]: unknown; 260 - }; 258 + $type: string; 259 + [key: string]: unknown; 260 + }; 261 261 createdAt?: string; 262 262 pinnedPost?: ComAtprotoRepoStrongRef; 263 263 /** Free-form profile description text. */ ··· 415 415 readonly SelfLabel: ComAtprotoLabelDefsSelfLabel; 416 416 readonly SelfLabels: ComAtprotoLabelDefsSelfLabels; 417 417 readonly LabelValueDefinition: ComAtprotoLabelDefsLabelValueDefinition; 418 - readonly LabelValueDefinitionStrings: ComAtprotoLabelDefsLabelValueDefinitionStrings; 418 + readonly LabelValueDefinitionStrings: 419 + ComAtprotoLabelDefsLabelValueDefinitionStrings; 419 420 } 420 421 421 422 class ProfileActorBskyAppClient { ··· 429 430 limit?: number; 430 431 cursor?: string; 431 432 where?: { 432 - [K in 433 - | AppBskyActorProfileSortFields 434 - | IndexedRecordFields]?: WhereCondition; 433 + [ 434 + K in 435 + | AppBskyActorProfileSortFields 436 + | IndexedRecordFields 437 + ]?: WhereCondition; 435 438 }; 436 439 orWhere?: { 437 - [K in 438 - | AppBskyActorProfileSortFields 439 - | IndexedRecordFields]?: WhereCondition; 440 + [ 441 + K in 442 + | AppBskyActorProfileSortFields 443 + | IndexedRecordFields 444 + ]?: WhereCondition; 440 445 }; 441 446 sortBy?: SortField<AppBskyActorProfileSortFields>[]; 442 447 }): Promise<GetRecordsResponse<AppBskyActorProfile>> { ··· 444 449 } 445 450 446 451 async getRecord( 447 - params: GetRecordParams 452 + params: GetRecordParams, 448 453 ): Promise<RecordResponse<AppBskyActorProfile>> { 449 454 return await this.client.getRecord("app.bsky.actor.profile", params); 450 455 } ··· 453 458 limit?: number; 454 459 cursor?: string; 455 460 where?: { 456 - [K in 457 - | AppBskyActorProfileSortFields 458 - | IndexedRecordFields]?: WhereCondition; 461 + [ 462 + K in 463 + | AppBskyActorProfileSortFields 464 + | IndexedRecordFields 465 + ]?: WhereCondition; 459 466 }; 460 467 orWhere?: { 461 - [K in 462 - | AppBskyActorProfileSortFields 463 - | IndexedRecordFields]?: WhereCondition; 468 + [ 469 + K in 470 + | AppBskyActorProfileSortFields 471 + | IndexedRecordFields 472 + ]?: WhereCondition; 464 473 }; 465 474 sortBy?: SortField<AppBskyActorProfileSortFields>[]; 466 475 }): Promise<CountRecordsResponse> { ··· 469 478 470 479 async createRecord( 471 480 record: AppBskyActorProfile, 472 - useSelfRkey?: boolean 481 + useSelfRkey?: boolean, 473 482 ): Promise<{ uri: string; cid: string }> { 474 483 return await this.client.createRecord( 475 484 "app.bsky.actor.profile", 476 485 record, 477 - useSelfRkey 486 + useSelfRkey, 478 487 ); 479 488 } 480 489 481 490 async updateRecord( 482 491 rkey: string, 483 - record: AppBskyActorProfile 492 + record: AppBskyActorProfile, 484 493 ): Promise<{ uri: string; cid: string }> { 485 494 return await this.client.updateRecord( 486 495 "app.bsky.actor.profile", 487 496 rkey, 488 - record 497 + record, 489 498 ); 490 499 } 491 500 ··· 535 544 limit?: number; 536 545 cursor?: string; 537 546 where?: { 538 - [K in 539 - | NetworkSlicesSliceSortFields 540 - | IndexedRecordFields]?: WhereCondition; 547 + [ 548 + K in 549 + | NetworkSlicesSliceSortFields 550 + | IndexedRecordFields 551 + ]?: WhereCondition; 541 552 }; 542 553 orWhere?: { 543 - [K in 544 - | NetworkSlicesSliceSortFields 545 - | IndexedRecordFields]?: WhereCondition; 554 + [ 555 + K in 556 + | NetworkSlicesSliceSortFields 557 + | IndexedRecordFields 558 + ]?: WhereCondition; 546 559 }; 547 560 sortBy?: SortField<NetworkSlicesSliceSortFields>[]; 548 561 }): Promise<GetRecordsResponse<NetworkSlicesSlice>> { ··· 550 563 } 551 564 552 565 async getRecord( 553 - params: GetRecordParams 566 + params: GetRecordParams, 554 567 ): Promise<RecordResponse<NetworkSlicesSlice>> { 555 568 return await this.client.getRecord("network.slices.slice", params); 556 569 } ··· 559 572 limit?: number; 560 573 cursor?: string; 561 574 where?: { 562 - [K in 563 - | NetworkSlicesSliceSortFields 564 - | IndexedRecordFields]?: WhereCondition; 575 + [ 576 + K in 577 + | NetworkSlicesSliceSortFields 578 + | IndexedRecordFields 579 + ]?: WhereCondition; 565 580 }; 566 581 orWhere?: { 567 - [K in 568 - | NetworkSlicesSliceSortFields 569 - | IndexedRecordFields]?: WhereCondition; 582 + [ 583 + K in 584 + | NetworkSlicesSliceSortFields 585 + | IndexedRecordFields 586 + ]?: WhereCondition; 570 587 }; 571 588 sortBy?: SortField<NetworkSlicesSliceSortFields>[]; 572 589 }): Promise<CountRecordsResponse> { ··· 575 592 576 593 async createRecord( 577 594 record: NetworkSlicesSlice, 578 - useSelfRkey?: boolean 595 + useSelfRkey?: boolean, 579 596 ): Promise<{ uri: string; cid: string }> { 580 597 return await this.client.createRecord( 581 598 "network.slices.slice", 582 599 record, 583 - useSelfRkey 600 + useSelfRkey, 584 601 ); 585 602 } 586 603 587 604 async updateRecord( 588 605 rkey: string, 589 - record: NetworkSlicesSlice 606 + record: NetworkSlicesSlice, 590 607 ): Promise<{ uri: string; cid: string }> { 591 608 return await this.client.updateRecord("network.slices.slice", rkey, record); 592 609 } ··· 599 616 return await this.client.makeRequest<CodegenXrpcResponse>( 600 617 "network.slices.slice.codegen", 601 618 "POST", 602 - request 619 + request, 603 620 ); 604 621 } 605 622 ··· 607 624 return await this.client.makeRequest<SliceStatsOutput>( 608 625 "network.slices.slice.stats", 609 626 "POST", 610 - params 627 + params, 611 628 ); 612 629 } 613 630 614 631 async getSliceRecords<T = Record<string, unknown>>( 615 - params: Omit<SliceLevelRecordsParams<T>, "slice"> 632 + params: Omit<SliceLevelRecordsParams<T>, "slice">, 616 633 ): Promise<SliceRecordsOutput<T>> { 617 634 // Combine where and orWhere into the expected backend format 618 635 const whereClause: any = params?.where ? { ...params.where } : {}; ··· 629 646 return await this.client.makeRequest<SliceRecordsOutput<T>>( 630 647 "network.slices.slice.getSliceRecords", 631 648 "POST", 632 - requestParams 649 + requestParams, 633 650 ); 634 651 } 635 652 ··· 638 655 return await this.client.makeRequest<GetActorsResponse>( 639 656 "network.slices.slice.getActors", 640 657 "POST", 641 - requestParams 658 + requestParams, 642 659 ); 643 660 } 644 661 ··· 647 664 return await this.client.makeRequest<SyncJobResponse>( 648 665 "network.slices.slice.startSync", 649 666 "POST", 650 - requestParams 667 + requestParams, 651 668 ); 652 669 } 653 670 ··· 655 672 return await this.client.makeRequest<JobStatus>( 656 673 "network.slices.slice.getJobStatus", 657 674 "GET", 658 - params 675 + params, 659 676 ); 660 677 } 661 678 662 679 async getJobHistory( 663 - params: GetJobHistoryParams 680 + params: GetJobHistoryParams, 664 681 ): Promise<GetJobHistoryResponse> { 665 682 return await this.client.makeRequest<GetJobHistoryResponse>( 666 683 "network.slices.slice.getJobHistory", 667 684 "GET", 668 - params 685 + params, 669 686 ); 670 687 } 671 688 ··· 673 690 return await this.client.makeRequest<GetJobLogsResponse>( 674 691 "network.slices.slice.getJobLogs", 675 692 "GET", 676 - params 693 + params, 677 694 ); 678 695 } 679 696 680 697 async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 681 698 return await this.client.makeRequest<JetstreamStatusResponse>( 682 699 "network.slices.slice.getJetstreamStatus", 683 - "GET" 700 + "GET", 684 701 ); 685 702 } 686 703 687 704 async getJetstreamLogs( 688 - params: GetJetstreamLogsParams 705 + params: GetJetstreamLogsParams, 689 706 ): Promise<GetJetstreamLogsResponse> { 690 707 return await this.client.makeRequest<GetJetstreamLogsResponse>( 691 708 "network.slices.slice.getJetstreamLogs", 692 709 "GET", 693 - params 710 + params, 694 711 ); 695 712 } 696 713 697 714 async syncUserCollections( 698 - params?: SyncUserCollectionsRequest 715 + params?: SyncUserCollectionsRequest, 699 716 ): Promise<SyncUserCollectionsResult> { 700 717 const requestParams = { slice: this.client.sliceUri, ...params }; 701 718 return await this.client.makeRequest<SyncUserCollectionsResult>( 702 719 "network.slices.slice.syncUserCollections", 703 720 "POST", 704 - requestParams 721 + requestParams, 705 722 ); 706 723 } 707 724 708 725 async createOAuthClient( 709 - params: CreateOAuthClientRequest 726 + params: CreateOAuthClientRequest, 710 727 ): Promise<OAuthClientDetails> { 711 728 const requestParams = { ...params, sliceUri: this.client.sliceUri }; 712 729 return await this.client.makeRequest<OAuthClientDetails>( 713 730 "network.slices.slice.createOAuthClient", 714 731 "POST", 715 - requestParams 732 + requestParams, 716 733 ); 717 734 } 718 735 ··· 721 738 return await this.client.makeRequest<ListOAuthClientsResponse>( 722 739 "network.slices.slice.getOAuthClients", 723 740 "GET", 724 - requestParams 741 + requestParams, 725 742 ); 726 743 } 727 744 728 745 async updateOAuthClient( 729 - params: UpdateOAuthClientRequest 746 + params: UpdateOAuthClientRequest, 730 747 ): Promise<OAuthClientDetails> { 731 748 const requestParams = { ...params, sliceUri: this.client.sliceUri }; 732 749 return await this.client.makeRequest<OAuthClientDetails>( 733 750 "network.slices.slice.updateOAuthClient", 734 751 "POST", 735 - requestParams 752 + requestParams, 736 753 ); 737 754 } 738 755 739 756 async deleteOAuthClient( 740 - clientId: string 757 + clientId: string, 741 758 ): Promise<DeleteOAuthClientResponse> { 742 759 return await this.client.makeRequest<DeleteOAuthClientResponse>( 743 760 "network.slices.slice.deleteOAuthClient", 744 761 "POST", 745 - { clientId } 762 + { clientId }, 746 763 ); 747 764 } 748 765 } ··· 758 775 limit?: number; 759 776 cursor?: string; 760 777 where?: { 761 - [K in 762 - | NetworkSlicesWaitingSortFields 763 - | IndexedRecordFields]?: WhereCondition; 778 + [ 779 + K in 780 + | NetworkSlicesWaitingSortFields 781 + | IndexedRecordFields 782 + ]?: WhereCondition; 764 783 }; 765 784 orWhere?: { 766 - [K in 767 - | NetworkSlicesWaitingSortFields 768 - | IndexedRecordFields]?: WhereCondition; 785 + [ 786 + K in 787 + | NetworkSlicesWaitingSortFields 788 + | IndexedRecordFields 789 + ]?: WhereCondition; 769 790 }; 770 791 sortBy?: SortField<NetworkSlicesWaitingSortFields>[]; 771 792 }): Promise<GetRecordsResponse<NetworkSlicesWaiting>> { ··· 773 794 } 774 795 775 796 async getRecord( 776 - params: GetRecordParams 797 + params: GetRecordParams, 777 798 ): Promise<RecordResponse<NetworkSlicesWaiting>> { 778 799 return await this.client.getRecord("network.slices.waiting", params); 779 800 } ··· 782 803 limit?: number; 783 804 cursor?: string; 784 805 where?: { 785 - [K in 786 - | NetworkSlicesWaitingSortFields 787 - | IndexedRecordFields]?: WhereCondition; 806 + [ 807 + K in 808 + | NetworkSlicesWaitingSortFields 809 + | IndexedRecordFields 810 + ]?: WhereCondition; 788 811 }; 789 812 orWhere?: { 790 - [K in 791 - | NetworkSlicesWaitingSortFields 792 - | IndexedRecordFields]?: WhereCondition; 813 + [ 814 + K in 815 + | NetworkSlicesWaitingSortFields 816 + | IndexedRecordFields 817 + ]?: WhereCondition; 793 818 }; 794 819 sortBy?: SortField<NetworkSlicesWaitingSortFields>[]; 795 820 }): Promise<CountRecordsResponse> { ··· 798 823 799 824 async createRecord( 800 825 record: NetworkSlicesWaiting, 801 - useSelfRkey?: boolean 826 + useSelfRkey?: boolean, 802 827 ): Promise<{ uri: string; cid: string }> { 803 828 return await this.client.createRecord( 804 829 "network.slices.waiting", 805 830 record, 806 - useSelfRkey 831 + useSelfRkey, 807 832 ); 808 833 } 809 834 810 835 async updateRecord( 811 836 rkey: string, 812 - record: NetworkSlicesWaiting 837 + record: NetworkSlicesWaiting, 813 838 ): Promise<{ uri: string; cid: string }> { 814 839 return await this.client.updateRecord( 815 840 "network.slices.waiting", 816 841 rkey, 817 - record 842 + record, 818 843 ); 819 844 } 820 845 ··· 834 859 limit?: number; 835 860 cursor?: string; 836 861 where?: { 837 - [K in 838 - | NetworkSlicesLexiconSortFields 839 - | IndexedRecordFields]?: WhereCondition; 862 + [ 863 + K in 864 + | NetworkSlicesLexiconSortFields 865 + | IndexedRecordFields 866 + ]?: WhereCondition; 840 867 }; 841 868 orWhere?: { 842 - [K in 843 - | NetworkSlicesLexiconSortFields 844 - | IndexedRecordFields]?: WhereCondition; 869 + [ 870 + K in 871 + | NetworkSlicesLexiconSortFields 872 + | IndexedRecordFields 873 + ]?: WhereCondition; 845 874 }; 846 875 sortBy?: SortField<NetworkSlicesLexiconSortFields>[]; 847 876 }): Promise<GetRecordsResponse<NetworkSlicesLexicon>> { ··· 849 878 } 850 879 851 880 async getRecord( 852 - params: GetRecordParams 881 + params: GetRecordParams, 853 882 ): Promise<RecordResponse<NetworkSlicesLexicon>> { 854 883 return await this.client.getRecord("network.slices.lexicon", params); 855 884 } ··· 858 887 limit?: number; 859 888 cursor?: string; 860 889 where?: { 861 - [K in 862 - | NetworkSlicesLexiconSortFields 863 - | IndexedRecordFields]?: WhereCondition; 890 + [ 891 + K in 892 + | NetworkSlicesLexiconSortFields 893 + | IndexedRecordFields 894 + ]?: WhereCondition; 864 895 }; 865 896 orWhere?: { 866 - [K in 867 - | NetworkSlicesLexiconSortFields 868 - | IndexedRecordFields]?: WhereCondition; 897 + [ 898 + K in 899 + | NetworkSlicesLexiconSortFields 900 + | IndexedRecordFields 901 + ]?: WhereCondition; 869 902 }; 870 903 sortBy?: SortField<NetworkSlicesLexiconSortFields>[]; 871 904 }): Promise<CountRecordsResponse> { ··· 874 907 875 908 async createRecord( 876 909 record: NetworkSlicesLexicon, 877 - useSelfRkey?: boolean 910 + useSelfRkey?: boolean, 878 911 ): Promise<{ uri: string; cid: string }> { 879 912 return await this.client.createRecord( 880 913 "network.slices.lexicon", 881 914 record, 882 - useSelfRkey 915 + useSelfRkey, 883 916 ); 884 917 } 885 918 886 919 async updateRecord( 887 920 rkey: string, 888 - record: NetworkSlicesLexicon 921 + record: NetworkSlicesLexicon, 889 922 ): Promise<{ uri: string; cid: string }> { 890 923 return await this.client.updateRecord( 891 924 "network.slices.lexicon", 892 925 rkey, 893 - record 926 + record, 894 927 ); 895 928 } 896 929 ··· 910 943 limit?: number; 911 944 cursor?: string; 912 945 where?: { 913 - [K in 914 - | NetworkSlicesActorProfileSortFields 915 - | IndexedRecordFields]?: WhereCondition; 946 + [ 947 + K in 948 + | NetworkSlicesActorProfileSortFields 949 + | IndexedRecordFields 950 + ]?: WhereCondition; 916 951 }; 917 952 orWhere?: { 918 - [K in 919 - | NetworkSlicesActorProfileSortFields 920 - | IndexedRecordFields]?: WhereCondition; 953 + [ 954 + K in 955 + | NetworkSlicesActorProfileSortFields 956 + | IndexedRecordFields 957 + ]?: WhereCondition; 921 958 }; 922 959 sortBy?: SortField<NetworkSlicesActorProfileSortFields>[]; 923 960 }): Promise<GetRecordsResponse<NetworkSlicesActorProfile>> { ··· 925 962 } 926 963 927 964 async getRecord( 928 - params: GetRecordParams 965 + params: GetRecordParams, 929 966 ): Promise<RecordResponse<NetworkSlicesActorProfile>> { 930 967 return await this.client.getRecord("network.slices.actor.profile", params); 931 968 } ··· 934 971 limit?: number; 935 972 cursor?: string; 936 973 where?: { 937 - [K in 938 - | NetworkSlicesActorProfileSortFields 939 - | IndexedRecordFields]?: WhereCondition; 974 + [ 975 + K in 976 + | NetworkSlicesActorProfileSortFields 977 + | IndexedRecordFields 978 + ]?: WhereCondition; 940 979 }; 941 980 orWhere?: { 942 - [K in 943 - | NetworkSlicesActorProfileSortFields 944 - | IndexedRecordFields]?: WhereCondition; 981 + [ 982 + K in 983 + | NetworkSlicesActorProfileSortFields 984 + | IndexedRecordFields 985 + ]?: WhereCondition; 945 986 }; 946 987 sortBy?: SortField<NetworkSlicesActorProfileSortFields>[]; 947 988 }): Promise<CountRecordsResponse> { 948 989 return await this.client.countRecords( 949 990 "network.slices.actor.profile", 950 - params 991 + params, 951 992 ); 952 993 } 953 994 954 995 async createRecord( 955 996 record: NetworkSlicesActorProfile, 956 - useSelfRkey?: boolean 997 + useSelfRkey?: boolean, 957 998 ): Promise<{ uri: string; cid: string }> { 958 999 return await this.client.createRecord( 959 1000 "network.slices.actor.profile", 960 1001 record, 961 - useSelfRkey 1002 + useSelfRkey, 962 1003 ); 963 1004 } 964 1005 965 1006 async updateRecord( 966 1007 rkey: string, 967 - record: NetworkSlicesActorProfile 1008 + record: NetworkSlicesActorProfile, 968 1009 ): Promise<{ uri: string; cid: string }> { 969 1010 return await this.client.updateRecord( 970 1011 "network.slices.actor.profile", 971 1012 rkey, 972 - record 1013 + record, 973 1014 ); 974 1015 } 975 1016
+5 -2
frontend/src/config.ts
··· 19 19 ) { 20 20 throw new Error( 21 21 "Missing OAuth configuration. Please ensure .env file contains:\n" + 22 - "OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_AIP_BASE_URL, API_URL, SLICE_URI" 22 + "OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_AIP_BASE_URL, API_URL, SLICE_URI", 23 23 ); 24 24 } 25 25 ··· 47 47 // "repo:network.slices.waiting", 48 48 ], 49 49 }, 50 - oauthStorage 50 + oauthStorage, 51 51 ); 52 52 53 53 // Session setup (shared database) ··· 67 67 }); 68 68 69 69 export const atprotoClient = new AtProtoClient(API_URL, SLICE_URI, oauthClient); 70 + 71 + // Public client for unauthenticated requests 72 + export const publicClient = new AtProtoClient(API_URL, SLICE_URI);
+20 -20
frontend/src/features/auth/handlers.tsx
··· 14 14 15 15 const error = url.searchParams.get("error"); 16 16 return renderHTML( 17 - <LoginPage error={error || undefined} currentUser={context.currentUser} /> 17 + <LoginPage error={error || undefined} currentUser={context.currentUser} />, 18 18 ); 19 19 } 20 20 ··· 49 49 return Response.redirect( 50 50 new URL( 51 51 "/login?error=" + encodeURIComponent("OAuth initialization failed"), 52 - req.url 52 + req.url, 53 53 ), 54 - 302 54 + 302, 55 55 ); 56 56 } 57 57 } ··· 82 82 return Response.redirect( 83 83 new URL( 84 84 "/login?error=" + encodeURIComponent("Invalid OAuth callback"), 85 - req.url 85 + req.url, 86 86 ), 87 - 302 87 + 302, 88 88 ); 89 89 } 90 90 ··· 92 92 return Response.redirect( 93 93 new URL( 94 94 "/login?error=" + encodeURIComponent("OAuth client not configured"), 95 - req.url 95 + req.url, 96 96 ), 97 - 302 97 + 302, 98 98 ); 99 99 } 100 100 ··· 111 111 return Response.redirect( 112 112 new URL( 113 113 "/login?error=" + encodeURIComponent("Failed to create session"), 114 - req.url 114 + req.url, 115 115 ), 116 - 302 116 + 302, 117 117 ); 118 118 } 119 119 ··· 132 132 try { 133 133 if (!userInfo?.sub) { 134 134 console.log( 135 - "No user DID available, skipping external collections sync" 135 + "No user DID available, skipping external collections sync", 136 136 ); 137 137 } else { 138 138 // Check if user already has bsky profile synced 139 139 try { 140 - const profileCheck = 141 - await atprotoClient.app.bsky.actor.profile.getRecords({ 140 + const profileCheck = await atprotoClient.app.bsky.actor.profile 141 + .getRecords({ 142 142 where: { 143 143 did: { eq: userInfo.sub }, 144 144 }, ··· 155 155 } catch (_profileError) { 156 156 // If we can't check existing records, skip sync to be safe 157 157 console.log( 158 - "Could not check existing external collections, skipping sync" 158 + "Could not check existing external collections, skipping sync", 159 159 ); 160 160 } 161 161 } 162 162 } catch (error) { 163 163 console.log( 164 164 "Error during sync check, skipping external collections sync:", 165 - error 165 + error, 166 166 ); 167 167 } 168 168 ··· 181 181 return Response.redirect( 182 182 new URL( 183 183 "/login?error=" + encodeURIComponent("Authentication failed"), 184 - req.url 184 + req.url, 185 185 ), 186 - 302 186 + 302, 187 187 ); 188 188 } 189 189 } ··· 235 235 isWaitlistFlow: true, 236 236 handle, 237 237 redirectUri: "/auth/waitlist/callback", 238 - }) 238 + }), 239 239 ); 240 240 241 241 // Initiate OAuth with minimal scope for waitlist, passing state directly ··· 263 263 if (!code || !state) { 264 264 return Response.redirect( 265 265 new URL("/?error=invalid_callback", req.url), 266 - 302 266 + 302, 267 267 ); 268 268 } 269 269 ··· 282 282 if (!atprotoClient.oauth) { 283 283 return Response.redirect( 284 284 new URL("/?error=oauth_not_configured", req.url), 285 - 302 285 + 302, 286 286 ); 287 287 } 288 288 ··· 309 309 { 310 310 createdAt: new Date().toISOString(), 311 311 }, 312 - true 312 + true, 313 313 ); 314 314 } catch (error) { 315 315 console.error("Failed to create waitlist record:", error);
+4 -4
frontend/src/features/auth/templates/LoginPage.tsx
··· 9 9 10 10 export function LoginPage({ error, currentUser }: LoginPageProps) { 11 11 return ( 12 - <Layout 13 - title="Login - Slice" 12 + <Layout 13 + title="Login - Slice" 14 14 currentUser={currentUser} 15 15 backgroundStyle="background-image: url('https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreidhffhdfqpxw6hxigkgbamn2kltohplzlepepxuogl7oh2bzoajwi@jpeg'); background-size: cover; background-position: center;" 16 16 > ··· 24 24 e.g., user.bsky.social, pds.example.com 25 25 </div> 26 26 </div> 27 - 27 + 28 28 <div className="absolute bottom-2 left-2 right-2 flex flex-col sm:flex-row justify-between items-start sm:items-end text-white text-sm gap-1 sm:gap-0"> 29 29 <div className="flex flex-col sm:flex-row"> 30 30 <span> ··· 65 65 </div> 66 66 </Layout> 67 67 ); 68 - } 68 + }
+1 -1
frontend/src/features/auth/templates/fragments/ErrorAlert.tsx
··· 8 8 <p className="text-red-700 text-sm">{message}</p> 9 9 </div> 10 10 ); 11 - } 11 + }
+1 -1
frontend/src/features/auth/templates/fragments/LoginForm.tsx
··· 35 35 )} 36 36 </form> 37 37 ); 38 - } 38 + }
+1 -1
frontend/src/features/auth/templates/fragments/WaitlistSuccess.tsx
··· 25 25 </div> 26 26 </div> 27 27 ); 28 - } 28 + }
+23 -17
frontend/src/features/dashboard/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { renderHTML } from "../../utils/render.tsx"; 3 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 - import { withAuth, requireAuth } from "../../routes/middleware.ts"; 5 - import { atprotoClient } from "../../config.ts"; 4 + import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 + import { atprotoClient, publicClient } from "../../config.ts"; 6 6 import { DashboardPage } from "./templates/DashboardPage.tsx"; 7 7 import { CreateSliceDialog } from "./templates/fragments/CreateSliceDialog.tsx"; 8 - import { getSlicesForActor, getSliceActor } from "../../lib/api.ts"; 8 + import { getSliceActor, getSlicesForActor } from "../../lib/api.ts"; 9 + import { buildSliceUrl } from "../../utils/slice-params.ts"; 9 10 10 11 async function handleProfilePage( 11 12 req: Request, 12 - params?: URLPatternResult 13 + params?: URLPatternResult, 13 14 ): Promise<Response> { 14 15 const context = await withAuth(req); 15 - const authResponse = requireAuth(context); 16 - if (authResponse) return authResponse; 17 16 18 17 const handle = params?.pathname.groups.handle as string; 19 18 20 19 // Get actor by handle to find DID 21 20 let profileDid: string; 22 21 try { 23 - const actors = await atprotoClient.getActors({ 22 + const actors = await publicClient.getActors({ 24 23 where: { handle: { eq: handle } }, 25 24 }); 26 25 ··· 36 35 37 36 // Use hydrated functions to get profile and slices 38 37 const [profile, slices] = await Promise.all([ 39 - getSliceActor(atprotoClient, profileDid), 40 - getSlicesForActor(atprotoClient, profileDid), 38 + getSliceActor(publicClient, profileDid), 39 + getSlicesForActor(publicClient, profileDid), 41 40 ]); 42 41 43 42 if (!profile) { ··· 49 48 slices={slices} 50 49 currentUser={context.currentUser} 51 50 profile={profile} 52 - /> 51 + />, 53 52 ); 54 53 } 55 54 ··· 61 60 const authInfo = await atprotoClient.oauth?.getAuthenticationInfo(); 62 61 if (!authInfo?.isAuthenticated) { 63 62 return renderHTML( 64 - <CreateSliceDialog error="Session expired. Please log in again." /> 63 + <CreateSliceDialog error="Session expired. Please log in again." />, 65 64 ); 66 65 } 67 66 ··· 76 75 error="Slice name is required" 77 76 name={name} 78 77 domain={domain} 79 - /> 78 + />, 80 79 ); 81 80 } 82 81 ··· 86 85 error="Primary domain is required" 87 86 name={name} 88 87 domain={domain} 89 - /> 88 + />, 90 89 ); 91 90 } 92 91 ··· 98 97 }; 99 98 100 99 const result = await atprotoClient.network.slices.slice.createRecord( 101 - recordData 100 + recordData, 102 101 ); 103 102 104 103 const uriParts = result.uri.split("/"); 105 - const sliceId = uriParts[uriParts.length - 1]; 104 + const sliceRkey = uriParts[uriParts.length - 1]; 106 105 107 - return hxRedirect(`/slices/${sliceId}`); 106 + // Get the user's handle from the auth context 107 + const handle = context.currentUser?.handle; 108 + if (!handle) { 109 + throw new Error("Unable to determine user handle"); 110 + } 111 + 112 + const redirectUrl = buildSliceUrl(handle, sliceRkey); 113 + return hxRedirect(redirectUrl); 108 114 } catch (_createError) { 109 115 return renderHTML( 110 116 <CreateSliceDialog 111 117 error="Failed to create slice record. Please try again." 112 118 name={name} 113 119 domain={domain} 114 - /> 120 + />, 115 121 ); 116 122 } 117 123 } catch (_error) {
+56 -33
frontend/src/features/dashboard/templates/DashboardPage.tsx
··· 1 1 import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 2 import { Button } from "../../../shared/fragments/Button.tsx"; 3 3 import { EmptyState } from "../../../shared/fragments/EmptyState.tsx"; 4 - import { SlicesList } from "./fragments/SlicesList.tsx"; 4 + import { SliceCard } from "../../../shared/fragments/SliceCard.tsx"; 5 5 import { ActorAvatar } from "../../../shared/fragments/ActorAvatar.tsx"; 6 6 import { FileText } from "lucide-preact"; 7 7 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 8 8 import type { 9 + NetworkSlicesActorDefsProfileViewBasic, 9 10 NetworkSlicesSliceDefsSliceView, 10 - NetworkSlicesActorDefsProfileViewBasic 11 11 } from "../../../client.ts"; 12 12 13 13 interface DashboardPageProps { ··· 23 23 }: DashboardPageProps) { 24 24 const displayName = profile?.displayName || profile?.handle || "User"; 25 25 26 + // Check if current user is viewing their own profile 27 + const isOwnProfile = currentUser?.isAuthenticated && 28 + currentUser?.handle === profile?.handle; 29 + 26 30 return ( 27 31 <Layout title="Slices" currentUser={currentUser}> 28 32 <div className="px-4 py-8"> ··· 30 34 {profile && ( 31 35 <div className="flex flex-col mb-8"> 32 36 <ActorAvatar profile={profile} size={64} /> 33 - <p className="text-2xl font-bold text-zinc-900 mt-2">{displayName}</p> 37 + <p className="text-2xl font-bold text-zinc-900 mt-2"> 38 + {displayName} 39 + </p> 34 40 <p className="text-zinc-600">@{profile.handle}</p> 35 41 {profile.description && ( 36 - <p className="text-zinc-600 mt-2 max-w-lg">{profile.description}</p> 42 + <p className="text-zinc-600 mt-2 max-w-lg"> 43 + {profile.description} 44 + </p> 37 45 )} 38 46 </div> 39 47 )} 40 48 41 49 <div className="flex justify-between items-center mb-8"> 42 50 <h1 className="text-3xl font-bold text-zinc-900">Slices</h1> 43 - <Button 44 - type="button" 45 - variant="primary" 46 - hx-get="/dialogs/create-slice" 47 - hx-target="body" 48 - hx-swap="beforeend" 49 - > 50 - + Create Slice 51 - </Button> 51 + {isOwnProfile && ( 52 + <Button 53 + type="button" 54 + variant="primary" 55 + hx-get="/dialogs/create-slice" 56 + hx-target="body" 57 + hx-swap="beforeend" 58 + > 59 + + Create Slice 60 + </Button> 61 + )} 52 62 </div> 53 63 54 - {slices.length > 0 ? ( 55 - <SlicesList slices={slices} /> 56 - ) : ( 57 - <div className="bg-white border border-zinc-200"> 58 - <EmptyState 59 - icon={<FileText size={64} strokeWidth={1} />} 60 - title="No slices yet" 61 - description="Create your first slice to get started organizing your AT Protocol data." 62 - > 63 - <Button 64 - type="button" 65 - variant="primary" 66 - hx-get="/dialogs/create-slice" 67 - hx-target="body" 68 - hx-swap="beforeend" 64 + {slices.length > 0 65 + ? ( 66 + <div className="space-y-4"> 67 + {slices.map((slice) => ( 68 + <SliceCard 69 + key={slice.uri} 70 + slice={slice} 71 + /> 72 + ))} 73 + </div> 74 + ) 75 + : ( 76 + <div className="bg-white border border-zinc-200"> 77 + <EmptyState 78 + icon={<FileText size={64} strokeWidth={1} />} 79 + title="No slices yet" 80 + description={isOwnProfile 81 + ? "Create your first slice to get started organizing your AT Protocol data." 82 + : "This user hasn't created any slices yet."} 69 83 > 70 - Create Your First Slice 71 - </Button> 72 - </EmptyState> 73 - </div> 74 - )} 84 + {isOwnProfile && ( 85 + <Button 86 + type="button" 87 + variant="primary" 88 + hx-get="/dialogs/create-slice" 89 + hx-target="body" 90 + hx-swap="beforeend" 91 + > 92 + Create Your First Slice 93 + </Button> 94 + )} 95 + </EmptyState> 96 + </div> 97 + )} 75 98 </div> 76 99 </Layout> 77 100 );
+1 -1
frontend/src/features/dashboard/templates/fragments/CreateSliceDialog.tsx
··· 99 99 </div> 100 100 </div> 101 101 ); 102 - } 102 + }
+91 -38
frontend/src/features/docs/handlers.tsx
··· 7 7 8 8 // List of available docs 9 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" }, 10 + { 11 + slug: "getting-started", 12 + title: "Getting Started", 13 + description: "Learn how to set up and use Slices", 14 + }, 15 + { 16 + slug: "concepts", 17 + title: "Core Concepts", 18 + description: "Understand slices, lexicons, and collections", 19 + }, 20 + { 21 + slug: "api-reference", 22 + title: "API Reference", 23 + description: "Complete endpoint documentation", 24 + }, 25 + { 26 + slug: "sdk-usage", 27 + title: "SDK Usage", 28 + description: "Advanced client patterns and examples", 29 + }, 14 30 ]; 15 31 16 32 const DOCS_PATH = Deno.env.get("DOCS_PATH") || "../docs"; ··· 46 62 }); 47 63 48 64 // 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>`; 65 + const styledCode = 66 + `<div class="my-4 [&_pre]:p-4 [&_pre]:rounded-md [&_pre]:overflow-x-auto [&_pre]:text-sm">${highlightedCode}</div>`; 50 67 51 68 codeBlocks.push({ 52 69 placeholder, ··· 55 72 } catch (error) { 56 73 // Fallback to simple code block if Shiki fails 57 74 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>`; 75 + const fallback = 76 + `<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 77 codeBlocks.push({ 60 78 placeholder, 61 79 replacement: fallback, ··· 70 88 // Process other markdown elements 71 89 html = html 72 90 // 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>') 91 + .replace( 92 + /^#### `([^`]+)`$/gm, 93 + '<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>', 94 + ) 95 + .replace( 96 + /^### `([^`]+)`$/gm, 97 + '<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>', 98 + ) 99 + .replace( 100 + /^## `([^`]+)`$/gm, 101 + '<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>', 102 + ) 76 103 // 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>') 104 + .replace( 105 + /^#### (.*$)/gm, 106 + '<h4 class="text-base font-semibold text-zinc-900 mt-6 mb-3">$1</h4>', 107 + ) 108 + .replace( 109 + /^### (.*$)/gm, 110 + '<h3 class="text-lg font-semibold text-zinc-900 mt-8 mb-4">$1</h3>', 111 + ) 112 + .replace( 113 + /^## (.*$)/gm, 114 + '<h2 class="text-xl font-bold text-zinc-900 mt-10 mb-4">$1</h2>', 115 + ) 116 + .replace( 117 + /^# (.*$)/gm, 118 + '<h1 class="text-2xl font-bold text-zinc-900 mt-10 mb-6">$1</h1>', 119 + ) 81 120 // 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>') 121 + .replace( 122 + /`([^`]+)`/g, 123 + '<code class="bg-zinc-100 px-1.5 py-0.5 rounded text-sm font-mono font-normal">$1</code>', 124 + ) 83 125 // Bold 84 126 .replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>') 85 127 // Lists (handle both - and * syntax, process before italic to avoid conflicts) 86 - .replace(/^[\-\*] (.*$)/gm, '<li class="mb-1" data-type="unordered">$1</li>') 128 + .replace( 129 + /^[\-\*] (.*$)/gm, 130 + '<li class="mb-1" data-type="unordered">$1</li>', 131 + ) 87 132 // Numbered lists 88 133 .replace(/^\d+\. (.*$)/gm, '<li class="mb-1" data-type="ordered">$1</li>') 89 134 // Italic (use word boundaries to avoid matching list items) ··· 91 136 // Links (convert .md links to docs routes) 92 137 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { 93 138 // Convert relative .md links to docs routes 94 - if (url.endsWith('.md') && !url.startsWith('http')) { 95 - const slug = url.replace(/^\.\//, '').replace(/\.md$/, ''); 139 + if (url.endsWith(".md") && !url.startsWith("http")) { 140 + const slug = url.replace(/^\.\//, "").replace(/\.md$/, ""); 96 141 return `<a href="/docs/${slug}" class="text-blue-600 hover:text-blue-800 underline">${text}</a>`; 97 142 } 98 143 return `<a href="${url}" class="text-blue-600 hover:text-blue-800 underline">${text}</a>`; 99 144 }); 100 145 101 146 // 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 - }); 147 + html = html.replace( 148 + /(<li[^>]*data-type="unordered"[^>]*>.*?<\/li>\s*)+/gs, 149 + (match) => { 150 + const cleanedMatch = match.replace(/data-type="unordered"/g, ""); 151 + return `<ul class="list-disc list-inside my-4">${cleanedMatch}</ul>`; 152 + }, 153 + ); 106 154 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 - }); 155 + html = html.replace( 156 + /(<li[^>]*data-type="ordered"[^>]*>.*?<\/li>\s*)+/gs, 157 + (match) => { 158 + const cleanedMatch = match.replace(/data-type="ordered"/g, ""); 159 + return `<ol class="list-decimal list-inside my-4">${cleanedMatch}</ol>`; 160 + }, 161 + ); 111 162 112 163 // Process paragraphs 113 - html = html.split('\n\n') 114 - .map(paragraph => { 164 + html = html.split("\n\n") 165 + .map((paragraph) => { 115 166 const trimmed = paragraph.trim(); 116 - if (!trimmed) return ''; 117 - if (trimmed.startsWith('<') || trimmed.startsWith('__CODE_BLOCK_')) return trimmed; // Already HTML or placeholder 167 + if (!trimmed) return ""; 168 + if (trimmed.startsWith("<") || trimmed.startsWith("__CODE_BLOCK_")) { 169 + return trimmed; // Already HTML or placeholder 170 + } 118 171 return `<p class="mb-4 leading-relaxed">${trimmed}</p>`; 119 172 }) 120 - .join('\n'); 173 + .join("\n"); 121 174 122 175 // Finally, restore code blocks from placeholders 123 176 for (const { placeholder, replacement } of codeBlocks) { ··· 133 186 <DocsIndexPage 134 187 docs={AVAILABLE_DOCS} 135 188 currentUser={currentUser} 136 - /> 189 + />, 137 190 ); 138 191 } 139 192 140 193 async function handleDocsPage(request: Request): Promise<Response> { 141 194 const { currentUser } = await withAuth(request); 142 195 const url = new URL(request.url); 143 - const slug = url.pathname.split('/')[2]; // /docs/{slug} 196 + const slug = url.pathname.split("/")[2]; // /docs/{slug} 144 197 145 198 if (!slug) { 146 199 // Redirect to docs index 147 200 return new Response(null, { 148 201 status: 302, 149 - headers: { Location: '/docs' } 202 + headers: { Location: "/docs" }, 150 203 }); 151 204 } 152 205 153 206 // Check if slug is valid 154 - const docInfo = AVAILABLE_DOCS.find(doc => doc.slug === slug); 207 + const docInfo = AVAILABLE_DOCS.find((doc) => doc.slug === slug); 155 208 if (!docInfo) { 156 - return new Response('Documentation page not found', { status: 404 }); 209 + return new Response("Documentation page not found", { status: 404 }); 157 210 } 158 211 159 212 // Read markdown content 160 213 const markdownContent = await readMarkdownFile(slug); 161 214 if (!markdownContent) { 162 - return new Response('Documentation content not found', { status: 404 }); 215 + return new Response("Documentation content not found", { status: 404 }); 163 216 } 164 217 165 218 // Convert to HTML with Shiki syntax highlighting ··· 172 225 docs={AVAILABLE_DOCS} 173 226 currentSlug={slug} 174 227 currentUser={currentUser} 175 - /> 228 + />, 176 229 ); 177 230 } 178 231 ··· 193 246 pattern: new URLPattern({ pathname: "/docs/:slug" }), 194 247 handler: handleDocsPage, 195 248 }, 196 - ]; 249 + ];
+1 -1
frontend/src/features/docs/templates/DocsIndexPage.tsx
··· 44 44 </div> 45 45 </Layout> 46 46 ); 47 - } 47 + }
+13 -4
frontend/src/features/docs/templates/DocsPage.tsx
··· 15 15 currentUser?: AuthenticatedUser; 16 16 } 17 17 18 - export function DocsPage({ title, content, docs, currentSlug, currentUser }: DocsPageProps) { 18 + export function DocsPage( 19 + { title, content, docs, currentSlug, currentUser }: DocsPageProps, 20 + ) { 19 21 return ( 20 22 <Layout title={`${title} - Slices`} currentUser={currentUser}> 21 23 <div className="py-4 sm:py-8 px-4"> 22 24 {/* Mobile navigation dropdown */} 23 25 <div className="sm:hidden mb-6"> 24 - <label htmlFor="docs-nav" className="block text-sm font-medium text-zinc-700 mb-2"> 26 + <label 27 + htmlFor="docs-nav" 28 + className="block text-sm font-medium text-zinc-700 mb-2" 29 + > 25 30 Navigate to 26 31 </label> 27 32 <select ··· 31 36 _="on change set window.location to `/docs/${me.value}`" 32 37 > 33 38 {docs.map((doc) => ( 34 - <option key={doc.slug} value={doc.slug} selected={doc.slug === currentSlug}> 39 + <option 40 + key={doc.slug} 41 + value={doc.slug} 42 + selected={doc.slug === currentSlug} 43 + > 35 44 {doc.title} 36 45 </option> 37 46 ))} ··· 77 86 </div> 78 87 </Layout> 79 88 ); 80 - } 89 + }
+12 -6
frontend/src/features/landing/handlers.tsx
··· 2 2 import { renderHTML } from "../../utils/render.tsx"; 3 3 import { withAuth } from "../../routes/middleware.ts"; 4 4 import { LandingPage } from "./templates/LandingPage.tsx"; 5 + import { publicClient } from "../../config.ts"; 6 + import { getTimeline } from "../../lib/api.ts"; 5 7 6 8 async function handleLandingPage(req: Request): Promise<Response> { 7 9 const context = await withAuth(req); 8 - 10 + 9 11 // Check for waitlist success parameters 10 12 const url = new URL(req.url); 11 13 const waitlistSuccess = url.searchParams.get("waitlist") === "success"; 12 14 const handle = url.searchParams.get("handle"); 13 - 15 + 16 + // Fetch timeline slices 17 + const slices = await getTimeline(publicClient, 20); 18 + 14 19 return renderHTML( 15 - <LandingPage 16 - waitlistSuccess={waitlistSuccess} 20 + <LandingPage 21 + waitlistSuccess={waitlistSuccess} 17 22 handle={handle || undefined} 18 23 currentUser={context.currentUser} 19 - />, 24 + slices={slices} 25 + />, 20 26 { 21 27 title: "Slice - AT Protocol Data Management Platform", 22 28 description: 23 29 "Build, manage, and integrate with AT Protocol data effortlessly. Create custom lexicons, sync records, and generate TypeScript clients.", 24 - } 30 + }, 25 31 ); 26 32 } 27 33
+30 -16
frontend/src/features/landing/templates/LandingPage.tsx
··· 1 1 import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 - import { Button } from "../../../shared/fragments/Button.tsx"; 3 2 import { WaitlistFormModal } from "./fragments/WaitlistFormModal.tsx"; 4 3 import { WaitlistSuccessModal } from "./fragments/WaitlistSuccessModal.tsx"; 4 + import { SliceCard } from "../../../shared/fragments/SliceCard.tsx"; 5 5 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 6 + import type { NetworkSlicesSliceDefsSliceView } from "../../../client.ts"; 6 7 7 8 interface LandingPageProps { 8 9 waitlistSuccess?: boolean; 9 10 handle?: string; 10 11 currentUser?: AuthenticatedUser; 12 + slices?: NetworkSlicesSliceDefsSliceView[]; 11 13 } 12 14 13 15 export function LandingPage({ 14 16 waitlistSuccess, 15 17 handle, 16 18 currentUser, 19 + slices = [], 17 20 }: LandingPageProps = {}) { 18 21 return ( 19 22 <Layout ··· 26 29 <h1 className="text-3xl font-bold text-zinc-900">Timeline</h1> 27 30 </div> 28 31 29 - <div className="flex justify-center items-center min-h-[60vh]"> 30 - <div className="text-center"> 31 - <p className="text-zinc-600 mb-6"> 32 - Join the waitlist for early access to Slices 33 - </p> 34 - <Button 35 - type="button" 36 - variant="primary" 37 - _="on click call #waitlist-modal.showModal()" 38 - > 39 - Join the Waitlist 40 - </Button> 41 - </div> 42 - </div> 32 + {slices.length > 0 33 + ? ( 34 + <div className="space-y-4"> 35 + {slices.map((slice) => ( 36 + <SliceCard 37 + key={slice.uri} 38 + slice={slice} 39 + /> 40 + ))} 41 + </div> 42 + ) 43 + : ( 44 + <div className="flex justify-center items-center min-h-[60vh]"> 45 + <div className="text-center"> 46 + <p className="text-zinc-600 mb-6"> 47 + No slices yet. Create your first slice to get started! 48 + </p> 49 + {!currentUser?.isAuthenticated && ( 50 + <p className="text-zinc-500 text-sm"> 51 + Join the waitlist for early access to Slices 52 + </p> 53 + )} 54 + </div> 55 + </div> 56 + )} 43 57 44 58 {/* Modals */} 45 59 <WaitlistFormModal /> ··· 47 61 </div> 48 62 </Layout> 49 63 ); 50 - } 64 + }
+1 -1
frontend/src/features/landing/templates/fragments/WaitlistFormModal.tsx
··· 48 48 </div> 49 49 </dialog> 50 50 ); 51 - } 51 + }
+6 -4
frontend/src/features/landing/templates/fragments/WaitlistSuccessModal.tsx
··· 5 5 show?: boolean; 6 6 } 7 7 8 - export function WaitlistSuccessModal({ handle, show }: WaitlistSuccessModalProps) { 8 + export function WaitlistSuccessModal( 9 + { handle, show }: WaitlistSuccessModalProps, 10 + ) { 9 11 return ( 10 12 <dialog 11 13 id="waitlist-success-modal" ··· 22 24 </h2> 23 25 <p class="font-mono text-sm text-gray-600 mb-6"> 24 26 Thanks for joining,{" "} 25 - <span class="font-bold">{handle || "there"}</span>! 26 - We'll notify you when Slices is ready for you to try. 27 + <span class="font-bold">{handle || "there"}</span>! We'll notify you 28 + when Slices is ready for you to try. 27 29 </p> 28 30 <button 29 31 type="button" ··· 36 38 </div> 37 39 </dialog> 38 40 ); 39 - } 41 + }
+29 -14
frontend/src/features/settings/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { renderHTML } from "../../utils/render.tsx"; 3 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 - import { withAuth, requireAuth } from "../../routes/middleware.ts"; 4 + import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 5 import { atprotoClient } from "../../config.ts"; 6 6 import { buildAtUri } from "../../utils/at-uri.ts"; 7 7 import type { SocialSlicesActorProfile } from "../../client.ts"; ··· 21 21 22 22 let profile: 23 23 | { 24 - displayName?: string; 25 - description?: string; 26 - avatar?: string; 27 - } 24 + displayName?: string; 25 + description?: string; 26 + avatar?: string; 27 + } 28 28 | undefined; 29 29 30 30 try { 31 - const profileRecord = 32 - await atprotoClient.network.slices.actor.profile.getRecord({ 31 + const profileRecord = await atprotoClient.network.slices.actor.profile 32 + .getRecord({ 33 33 uri: buildAtUri({ 34 34 did: context.currentUser.sub!, 35 35 collection: "network.slices.actor.profile", ··· 41 41 displayName: profileRecord.value.displayName, 42 42 description: profileRecord.value.description, 43 43 avatar: profileRecord.value.avatar 44 - ? recordBlobToCdnUrl(profileRecord, profileRecord.value.avatar, "avatar") 44 + ? recordBlobToCdnUrl( 45 + profileRecord, 46 + profileRecord.value.avatar, 47 + "avatar", 48 + ) 45 49 : undefined, 46 50 }; 47 51 } ··· 55 59 currentUser={context.currentUser} 56 60 updated={updated === "true"} 57 61 error={error} 58 - /> 62 + />, 59 63 ); 60 64 } 61 65 ··· 96 100 throw new Error("User DID (sub) is required for profile operations"); 97 101 } 98 102 99 - const existingProfile = 100 - await atprotoClient.network.slices.actor.profile.getRecord({ 103 + const existingProfile = await atprotoClient.network.slices.actor.profile 104 + .getRecord({ 101 105 uri: buildAtUri({ 102 106 did: context.currentUser.sub, 103 107 collection: "network.slices.actor.profile", ··· 106 110 }); 107 111 108 112 if (existingProfile) { 109 - await atprotoClient.network.slices.actor.profile.updateRecord("self", { 113 + // Preserve existing avatar if no new avatar was uploaded 114 + const updatedProfile = { 110 115 ...profileData, 111 116 createdAt: existingProfile.value.createdAt, 112 - }); 117 + }; 118 + 119 + // Only update avatar if a new one was uploaded 120 + if (!profileData.avatar) { 121 + updatedProfile.avatar = existingProfile.value.avatar; 122 + } 123 + 124 + await atprotoClient.network.slices.actor.profile.updateRecord( 125 + "self", 126 + updatedProfile, 127 + ); 113 128 } else { 114 129 await atprotoClient.network.slices.actor.profile.createRecord( 115 130 profileData, 116 - true 131 + true, 117 132 ); 118 133 } 119 134
+11 -13
frontend/src/features/settings/templates/SettingsPage.tsx
··· 32 32 33 33 {/* Flash Messages */} 34 34 {updated && ( 35 - <FlashMessage 36 - type="success" 37 - message="Profile updated successfully!" 35 + <FlashMessage 36 + type="success" 37 + message="Profile updated successfully!" 38 38 /> 39 39 )} 40 40 41 41 {error && ( 42 - <FlashMessage 43 - type="error" 44 - message={ 45 - error === "update_failed" 46 - ? "Failed to update profile. Please try again." 47 - : error === "form_error" 48 - ? "Failed to process form data. Please try again." 49 - : "An error occurred." 50 - } 42 + <FlashMessage 43 + type="error" 44 + message={error === "update_failed" 45 + ? "Failed to update profile. Please try again." 46 + : error === "form_error" 47 + ? "Failed to process form data. Please try again." 48 + : "An error occurred."} 51 49 /> 52 50 )} 53 51 ··· 55 53 </div> 56 54 </Layout> 57 55 ); 58 - } 56 + }
+1 -1
frontend/src/features/settings/templates/fragments/SettingsForm.tsx
··· 65 65 </div> 66 66 </div> 67 67 ); 68 - } 68 + }
+4 -5
frontend/src/features/settings/templates/fragments/SettingsResult.tsx
··· 9 9 message, 10 10 showRefresh, 11 11 }: SettingsResultProps) { 12 - const bgColor = 13 - type === "success" 14 - ? "bg-green-50 border-green-200 text-green-700" 15 - : "bg-red-50 border-red-200 text-red-700"; 12 + const bgColor = type === "success" 13 + ? "bg-green-50 border-green-200 text-green-700" 14 + : "bg-red-50 border-red-200 text-red-700"; 16 15 const icon = type === "success" ? "✅" : "❌"; 17 16 18 17 return ( ··· 32 31 )} 33 32 </div> 34 33 ); 35 - } 34 + }
+14 -5
frontend/src/features/slices/api-docs/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { renderHTML } from "../../../utils/render.tsx"; 3 3 import { withAuth } from "../../../routes/middleware.ts"; 4 - import { withSliceAccess, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 4 + import { 5 + requireSliceAccess, 6 + withSliceAccess, 7 + } from "../../../routes/slice-middleware.ts"; 5 8 import { extractSliceParams } from "../../../utils/slice-params.ts"; 6 9 import { atprotoClient } from "../../../config.ts"; 7 10 import { SliceApiDocsPage } from "./templates/SliceApiDocsPage.tsx"; 8 11 9 12 async function handleSliceApiDocsPage( 10 13 req: Request, 11 - params?: URLPatternResult 14 + params?: URLPatternResult, 12 15 ): Promise<Response> { 13 16 const authContext = await withAuth(req); 14 17 const sliceParams = extractSliceParams(params); ··· 17 20 return Response.redirect(new URL("/", req.url), 302); 18 21 } 19 22 20 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 23 + const context = await withSliceAccess( 24 + authContext, 25 + sliceParams.handle, 26 + sliceParams.sliceId, 27 + ); 21 28 const accessError = requireSliceAccess(context); 22 29 if (accessError) return accessError; 23 30 ··· 37 44 sliceId={sliceParams.sliceId} 38 45 accessToken={accessToken} 39 46 currentUser={authContext.currentUser} 40 - /> 47 + />, 41 48 ); 42 49 } 43 50 44 51 export const apiDocsRoutes: Route[] = [ 45 52 { 46 53 method: "GET", 47 - pattern: new URLPattern({ pathname: "/profile/:handle/slice/:rkey/api-docs" }), 54 + pattern: new URLPattern({ 55 + pathname: "/profile/:handle/slice/:rkey/api-docs", 56 + }), 48 57 handler: handleSliceApiDocsPage, 49 58 }, 50 59 ];
+15 -10
frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx
··· 1 1 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 2 2 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 3 + import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 3 4 4 5 interface SliceApiDocsPageProps { 5 6 slice: NetworkSlicesSliceDefsSliceView; ··· 17 18 18 19 // Build the slice URI 19 20 const sliceUri = `at://${currentUser.sub}/network.slices.slice/${sliceId}`; 20 - const openApiUrl = `${baseUrl}/xrpc/network.slices.slice.openapi?slice=${encodeURIComponent( 21 - sliceUri 22 - )}`; 21 + const openApiUrl = `${baseUrl}/xrpc/network.slices.slice.openapi?slice=${ 22 + encodeURIComponent( 23 + sliceUri, 24 + ) 25 + }`; 23 26 24 27 return ( 25 28 <html lang="en"> ··· 35 38 <div class="max-w-7xl mx-auto flex items-center justify-between"> 36 39 <div class="flex items-center"> 37 40 <a 38 - href={`/slices/${sliceId}`} 41 + href={buildSliceUrlFromView(slice, sliceId)} 39 42 class="text-blue-600 hover:text-blue-800 mr-4 flex items-center" 40 43 > 41 44 <svg ··· 82 85 <div id="scalar-api-reference" class="w-full min-h-screen"> 83 86 <div class="flex items-center justify-center h-96"> 84 87 <div class="text-center"> 85 - <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div> 88 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"> 89 + </div> 86 90 <p class="text-gray-500">Loading API documentation...</p> 87 91 </div> 88 92 </div> ··· 93 97 <script 94 98 src="https://cdn.jsdelivr.net/npm/@scalar/api-reference" 95 99 async 96 - ></script> 100 + > 101 + </script> 97 102 98 103 {/* Initialize Scalar when the script loads */} 99 104 <script ··· 124 129 defaultParameters: { 125 130 slice: '${sliceUri}' 126 131 },${ 127 - accessToken 128 - ? ` 132 + accessToken 133 + ? ` 129 134 authentication: { 130 135 preferredSecurityScheme: 'bearerAuth', 131 136 http: { ··· 134 139 } 135 140 } 136 141 },` 137 - : "" 138 - } 142 + : "" 143 + } 139 144 customCss: \` 140 145 .scalar-api-reference { 141 146 width: 100% !important;
+50 -20
frontend/src/features/slices/codegen/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 - import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 2 + import { withAuth } from "../../../routes/middleware.ts"; 3 3 import { getSliceClient } from "../../../utils/client.ts"; 4 - import { buildSliceUri } from "../../../utils/at-uri.ts"; 5 4 import { renderHTML } from "../../../utils/render.tsx"; 6 - import { withSliceAccess, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 5 + import { withSliceAccess } from "../../../routes/slice-middleware.ts"; 7 6 import { extractSliceParams } from "../../../utils/slice-params.ts"; 8 7 import { SliceCodegenPage } from "./templates/SliceCodegenPage.tsx"; 9 8 import { CodegenResult } from "./templates/fragments/CodegenResult.tsx"; 10 9 11 10 async function handleSliceCodegenPage( 12 11 req: Request, 13 - params?: URLPatternResult 12 + params?: URLPatternResult, 14 13 ): Promise<Response> { 15 14 const authContext = await withAuth(req); 16 15 const sliceParams = extractSliceParams(params); ··· 19 18 return Response.redirect(new URL("/", req.url), 302); 20 19 } 21 20 22 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 23 - const accessError = requireSliceAccess(context); 24 - if (accessError) return accessError; 21 + const context = await withSliceAccess( 22 + authContext, 23 + sliceParams.handle, 24 + sliceParams.sliceId, 25 + ); 26 + 27 + // Check if slice exists (codegen page is public) 28 + if (!context.sliceContext?.slice) { 29 + return new Response("Slice not found", { status: 404 }); 30 + } 25 31 26 32 return renderHTML( 27 33 <SliceCodegenPage 28 34 slice={context.sliceContext!.slice!} 29 35 sliceId={sliceParams.sliceId} 30 36 currentUser={authContext.currentUser} 31 - /> 37 + hasSliceAccess={context.sliceContext?.hasAccess} 38 + />, 32 39 ); 33 40 } 34 41 35 42 async function handleSliceCodegen( 36 43 req: Request, 37 - params?: URLPatternResult 44 + params?: URLPatternResult, 38 45 ): Promise<Response> { 39 - const context = await withAuth(req); 40 - const authResponse = requireAuth(context); 41 - if (authResponse) return authResponse; 46 + const authContext = await withAuth(req); 42 47 43 48 const sliceId = params?.pathname.groups.id; 44 49 if (!sliceId) { ··· 49 54 return renderHTML(component, { status: 400 }); 50 55 } 51 56 57 + // Extract handle from form data 58 + const formData = await req.formData(); 59 + const handle = formData.get("handle") as string; 60 + 61 + if (!handle) { 62 + const component = await CodegenResult({ 63 + success: false, 64 + error: "Handle parameter required", 65 + }); 66 + return renderHTML(component, { status: 400 }); 67 + } 68 + 69 + const context = await withSliceAccess( 70 + authContext, 71 + handle, 72 + sliceId, 73 + ); 74 + 75 + // Check if slice exists (codegen is public) 76 + if (!context.sliceContext?.slice) { 77 + const component = await CodegenResult({ 78 + success: false, 79 + error: "Slice not found", 80 + }); 81 + return renderHTML(component, { status: 404 }); 82 + } 83 + 52 84 try { 53 85 // Parse form data 54 - const formData = await req.formData(); 55 86 const target = formData.get("format") || "typescript"; 56 87 57 - // Construct the slice URI 58 - const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 59 - 60 - // Use the slice-specific client 61 - const sliceClient = getSliceClient(context, sliceId); 88 + // Use the slice-specific client with owner DID 89 + const sliceClient = getSliceClient(authContext, sliceId, context.sliceContext.profileDid); 62 90 63 91 // Call the codegen XRPC endpoint 64 92 const result = await sliceClient.network.slices.slice.codegen({ 65 93 target: target as string, 66 - slice: sliceUri, 94 + slice: context.sliceContext!.sliceUri, 67 95 }); 68 96 69 97 const component = await CodegenResult({ ··· 87 115 export const codegenRoutes: Route[] = [ 88 116 { 89 117 method: "GET", 90 - pattern: new URLPattern({ pathname: "/profile/:handle/slice/:rkey/codegen" }), 118 + pattern: new URLPattern({ 119 + pathname: "/profile/:handle/slice/:rkey/codegen", 120 + }), 91 121 handler: handleSliceCodegenPage, 92 122 }, 93 123 {
+9 -4
frontend/src/features/slices/codegen/templates/SliceCodegenPage.tsx
··· 7 7 slice: NetworkSlicesSliceDefsSliceView; 8 8 sliceId: string; 9 9 currentUser?: AuthenticatedUser; 10 + hasSliceAccess?: boolean; 10 11 } 11 12 12 13 export function SliceCodegenPage({ 13 14 slice, 14 15 sliceId, 15 16 currentUser, 17 + hasSliceAccess, 16 18 }: SliceCodegenPageProps) { 17 19 return ( 18 20 <SlicePage ··· 20 22 sliceId={sliceId} 21 23 currentTab="codegen" 22 24 currentUser={currentUser} 25 + hasSliceAccess={hasSliceAccess} 23 26 title={`${slice.name} - Code Generation`} 24 27 > 25 - <CodegenForm sliceId={sliceId} /> 26 - 28 + <CodegenForm sliceId={sliceId} handle={slice.creator.handle} /> 29 + 27 30 <div className="bg-white border border-zinc-200"> 28 31 <div className="px-6 py-4 border-b border-zinc-200"> 29 - <h2 className="text-lg font-semibold text-zinc-900">Generated Client</h2> 32 + <h2 className="text-lg font-semibold text-zinc-900"> 33 + Generated Client 34 + </h2> 30 35 </div> 31 36 <div id="codegen-results"> 32 37 <div className="p-6 text-center text-zinc-500"> ··· 36 41 </div> 37 42 </SlicePage> 38 43 ); 39 - } 44 + }
+7 -4
frontend/src/features/slices/codegen/templates/fragments/CodegenForm.tsx
··· 3 3 4 4 interface CodegenFormProps { 5 5 sliceId: string; 6 + handle: string; 6 7 } 7 8 8 - export function CodegenForm({ sliceId }: CodegenFormProps) { 9 + export function CodegenForm({ sliceId, handle }: CodegenFormProps) { 9 10 return ( 10 11 <div className="bg-white border border-zinc-200 p-6 mb-6"> 11 12 <h2 className="text-xl font-semibold text-zinc-900 mb-4"> ··· 23 24 hx-on="htmx:afterRequest: if(event.detail.successful) document.getElementById('copy-button').style.display = 'block'" 24 25 className="space-y-4" 25 26 > 27 + <input type="hidden" name="handle" value={handle} /> 26 28 <Select label="Output Format" name="format"> 27 29 <option value="typescript">TypeScript</option> 28 30 </Select> ··· 37 39 data-lucide="loader-2" 38 40 className="htmx-indicator animate-spin mr-2 h-4 w-4" 39 41 _="on load js lucide.createIcons() end" 40 - ></i> 42 + > 43 + </i> 41 44 <span className="htmx-indicator">Generating Client...</span> 42 45 <span className="default-text">Generate Client</span> 43 46 </Button> 44 - 47 + 45 48 <Button 46 49 type="button" 47 50 variant="success" ··· 55 58 </form> 56 59 </div> 57 60 ); 58 - } 61 + }
+1 -1
frontend/src/features/slices/codegen/templates/fragments/CodegenResult.tsx
··· 14 14 if (success && generatedCode) { 15 15 const highlightedCode = await codeToHtml(generatedCode, { 16 16 lang: "typescript", 17 - theme: "catppuccin-latte", 17 + theme: "tokyo-night", 18 18 }); 19 19 20 20 return (
+39 -27
frontend/src/features/slices/jetstream/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 - import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 3 - import { withSliceAccess, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 2 + import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 3 + import { 4 + requireSliceAccess, 5 + withSliceAccess, 6 + } from "../../../routes/slice-middleware.ts"; 4 7 import { getSliceClient } from "../../../utils/client.ts"; 5 8 import { atprotoClient } from "../../../config.ts"; 6 9 import { renderHTML } from "../../../utils/render.tsx"; ··· 14 17 15 18 async function handleJetstreamLogs( 16 19 req: Request, 17 - params?: URLPatternResult 20 + params?: URLPatternResult, 18 21 ): Promise<Response> { 19 22 const context = await withAuth(req); 20 23 const authResponse = requireAuth(context); ··· 24 27 if (!sliceId) { 25 28 return renderHTML( 26 29 <div className="p-8 text-center text-red-600">❌ Invalid slice ID</div>, 27 - { status: 400 } 30 + { status: 400 }, 28 31 ); 29 32 } 30 33 ··· 42 45 // Sort logs in descending order (newest first) 43 46 const sortedLogs = logs.sort( 44 47 (a, b) => 45 - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 48 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 46 49 ); 47 50 48 51 // Render the log content ··· 69 72 </div> 70 73 </div> 71 74 </Layout>, 72 - { status: 500 } 75 + { status: 500 }, 73 76 ); 74 77 } 75 78 } 76 79 77 80 async function handleJetstreamStatus( 78 81 req: Request, 79 - _params?: URLPatternResult 82 + _params?: URLPatternResult, 80 83 ): Promise<Response> { 81 84 try { 82 85 // Extract parameters from query ··· 92 95 if (isCompact) { 93 96 return renderHTML( 94 97 <div className="inline-flex items-center gap-2 text-xs"> 95 - {data.connected ? ( 96 - <> 97 - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> 98 - <span className="text-green-700">Jetstream Connected</span> 99 - </> 100 - ) : ( 101 - <> 102 - <div className="w-2 h-2 bg-red-500 rounded-full"></div> 103 - <span className="text-red-700">Jetstream Offline</span> 104 - </> 105 - )} 106 - </div> 98 + {data.connected 99 + ? ( 100 + <> 101 + <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"> 102 + </div> 103 + <span className="text-green-700">Jetstream Connected</span> 104 + </> 105 + ) 106 + : ( 107 + <> 108 + <div className="w-2 h-2 bg-red-500 rounded-full"></div> 109 + <span className="text-red-700">Jetstream Offline</span> 110 + </> 111 + )} 112 + </div>, 107 113 ); 108 114 } 109 115 ··· 119 125 status={data.status} 120 126 error={data.error} 121 127 jetstreamUrl={jetstreamUrl} 122 - /> 128 + />, 123 129 ); 124 130 } catch (error) { 125 131 // Extract parameters for error case too ··· 134 140 <div className="inline-flex items-center gap-2 text-xs"> 135 141 <div className="w-2 h-2 bg-red-500 rounded-full"></div> 136 142 <span className="text-red-700">Jetstream Offline</span> 137 - </div> 143 + </div>, 138 144 ); 139 145 } 140 146 ··· 150 156 status="Connection error" 151 157 error={error instanceof Error ? error.message : "Unknown error"} 152 158 jetstreamUrl={jetstreamUrl} 153 - /> 159 + />, 154 160 ); 155 161 } 156 162 } 157 163 158 164 async function handleJetstreamLogsPage( 159 165 req: Request, 160 - params?: URLPatternResult 166 + params?: URLPatternResult, 161 167 ): Promise<Response> { 162 168 const authContext = await withAuth(req); 163 169 const sliceParams = extractSliceParams(params); ··· 166 172 return Response.redirect(new URL("/", req.url), 302); 167 173 } 168 174 169 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 175 + const context = await withSliceAccess( 176 + authContext, 177 + sliceParams.handle, 178 + sliceParams.sliceId, 179 + ); 170 180 const accessError = requireSliceAccess(context); 171 181 if (accessError) return accessError; 172 182 ··· 181 191 }); 182 192 logs = logsResult.logs.sort( 183 193 (a, b) => 184 - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 194 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 185 195 ); 186 196 } catch (error) { 187 197 console.error("Failed to fetch Jetstream logs:", error); ··· 193 203 logs={logs} 194 204 sliceId={sliceParams.sliceId} 195 205 currentUser={authContext.currentUser} 196 - /> 206 + />, 197 207 ); 198 208 } 199 209 200 210 export const jetstreamRoutes: Route[] = [ 201 211 { 202 212 method: "GET", 203 - pattern: new URLPattern({ pathname: "/profile/:handle/slice/:rkey/jetstream" }), 213 + pattern: new URLPattern({ 214 + pathname: "/profile/:handle/slice/:rkey/jetstream", 215 + }), 204 216 handler: handleJetstreamLogsPage, 205 217 }, 206 218 {
+6 -3
frontend/src/features/slices/jetstream/templates/JetstreamLogsPage.tsx
··· 1 - import type { LogEntry, NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 1 + import type { 2 + LogEntry, 3 + NetworkSlicesSliceDefsSliceView, 4 + } from "../../../../client.ts"; 2 5 import { SliceLogPage } from "../../shared/fragments/SliceLogPage.tsx"; 3 6 import { JetstreamLogs } from "./fragments/JetstreamLogs.tsx"; 4 7 import { JetstreamStatusCompact } from "./fragments/JetstreamStatusCompact.tsx"; ··· 25 28 title="Jetstream Logs" 26 29 headerActions={<JetstreamStatusCompact sliceId={sliceId} />} 27 30 > 28 - <div 31 + <div 29 32 className="bg-white border border-zinc-200" 30 33 hx-get={`/api/slices/${sliceId}/jetstream/logs`} 31 34 hx-trigger="load, every 20s" ··· 35 38 </div> 36 39 </SliceLogPage> 37 40 ); 38 - } 41 + }
+3 -3
frontend/src/features/slices/jetstream/templates/fragments/JetstreamLogs.tsx
··· 8 8 9 9 export function JetstreamLogs({ logs }: JetstreamLogsProps) { 10 10 return ( 11 - <LogViewer 12 - logs={logs} 11 + <LogViewer 12 + logs={logs} 13 13 emptyMessage="No Jetstream logs available for this slice." 14 14 formatTimestamp={formatTimestamp} 15 15 /> 16 16 ); 17 - } 17 + }
+3 -2
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatus.tsx
··· 18 18 <div className="bg-green-50 border border-green-200 p-4 mb-6"> 19 19 <div className="flex items-center justify-between"> 20 20 <div className="flex items-center"> 21 - <div className="w-3 h-3 bg-green-500 rounded-full mr-3 animate-pulse"></div> 21 + <div className="w-3 h-3 bg-green-500 rounded-full mr-3 animate-pulse"> 22 + </div> 22 23 <div> 23 24 <h3 className="text-sm font-semibold text-green-800"> 24 25 ✈️ Jetstream Connected ··· 78 79 </div> 79 80 ); 80 81 } 81 - } 82 + }
+1 -1
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatusCompact.tsx
··· 10 10 <span className="text-zinc-500">Checking status...</span> 11 11 </div> 12 12 ); 13 - } 13 + }
+143 -67
frontend/src/features/slices/lexicon/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { renderHTML } from "../../../utils/render.tsx"; 3 - import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 3 + import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 4 4 import { getSliceClient } from "../../../utils/client.ts"; 5 - import { buildSliceUri, buildAtUri } from "../../../utils/at-uri.ts"; 5 + import { buildAtUri, buildSliceUri } from "../../../utils/at-uri.ts"; 6 6 import { atprotoClient } from "../../../config.ts"; 7 7 import { 8 + requireSliceAccess, 8 9 withSliceAccess, 9 - requireSliceAccess, 10 10 } from "../../../routes/slice-middleware.ts"; 11 11 import { extractSliceParams } from "../../../utils/slice-params.ts"; 12 12 import { SliceLexiconPage } from "./templates/SliceLexiconPage.tsx"; ··· 15 15 import { LexiconSuccessMessage } from "./templates/fragments/LexiconSuccessMessage.tsx"; 16 16 import { LexiconErrorMessage } from "./templates/fragments/LexiconErrorMessage.tsx"; 17 17 import { LexiconsList } from "./templates/fragments/LexiconsList.tsx"; 18 + import { LexiconFormModal } from "./templates/fragments/LexiconFormModal.tsx"; 18 19 import { FileCode } from "lucide-preact"; 20 + import { buildSliceUrl } from "../../../utils/slice-params.ts"; 19 21 20 22 async function handleListLexicons( 21 23 req: Request, 22 - params?: URLPatternResult 24 + params?: URLPatternResult, 23 25 ): Promise<Response> { 24 - const context = await withAuth(req); 25 - const authResponse = requireAuth(context); 26 - if (authResponse) return authResponse; 26 + const authContext = await withAuth(req); 27 27 28 28 const sliceId = params?.pathname.groups.id; 29 29 if (!sliceId) { ··· 34 34 const url = new URL(req.url); 35 35 const handle = url.searchParams.get("handle"); 36 36 37 + if (!handle) { 38 + return new Response("Handle parameter required", { status: 400 }); 39 + } 40 + 41 + const context = await withSliceAccess( 42 + authContext, 43 + handle, 44 + sliceId, 45 + ); 46 + 47 + // Check if slice exists (lexicons list is public) 48 + if (!context.sliceContext?.slice) { 49 + return new Response("Slice not found", { status: 404 }); 50 + } 51 + 37 52 try { 38 - const sliceClient = getSliceClient(context, sliceId); 39 - const lexiconRecords = 40 - await sliceClient.network.slices.lexicon.getRecords(); 53 + const sliceClient = getSliceClient(authContext, sliceId, context.sliceContext.profileDid); 54 + const lexiconRecords = await sliceClient.network.slices.lexicon 55 + .getRecords(); 41 56 42 57 if (lexiconRecords.records.length === 0) { 58 + const isOwner = context.sliceContext?.hasAccess; 43 59 return renderHTML( 44 60 <EmptyState 45 61 icon={<FileCode size={64} strokeWidth={1} />} 46 - title="No lexicons uploaded" 47 - description="Upload lexicon definitions to define custom schemas for this slice." 62 + title={isOwner ? "No lexicons uploaded" : "No lexicons defined"} 63 + description={isOwner 64 + ? "Upload lexicon definitions to define custom schemas for this slice." 65 + : "This slice hasn't defined any lexicon schemas yet." 66 + } 48 67 withPadding 49 - /> 68 + />, 50 69 ); 51 70 } 52 71 72 + // Get slice info for domain comparison 73 + const sliceDomain = context.sliceContext!.slice!.domain; 74 + 53 75 return renderHTML( 54 76 <LexiconsList 55 77 records={lexiconRecords.records} 56 78 sliceId={sliceId} 57 79 handle={handle || undefined} 58 - /> 80 + sliceDomain={sliceDomain} 81 + hasSliceAccess={context.sliceContext?.hasAccess} 82 + />, 59 83 ); 60 84 } catch (error) { 61 85 console.error("Failed to fetch lexicons:", error); ··· 63 87 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 64 88 <p>Failed to load lexicons: {error}</p> 65 89 </div>, 66 - { status: 500 } 90 + { status: 500 }, 67 91 ); 68 92 } 69 93 } ··· 80 104 if (!lexiconJson || lexiconJson.trim().length === 0) { 81 105 return renderHTML( 82 106 <LexiconErrorMessage error="Lexicon JSON is required" />, 83 - { status: 400 } 84 107 ); 85 108 } 86 109 ··· 91 114 return renderHTML( 92 115 <LexiconErrorMessage 93 116 error={`Failed to parse lexicon JSON: ${parseError}`} 94 - /> 117 + />, 95 118 ); 96 119 } 97 120 98 121 if (!lexiconData.id && !lexiconData.nsid) { 99 122 return renderHTML( 100 - <LexiconErrorMessage error="Lexicon must have an 'id' field (e.g., 'com.example.myLexicon')" /> 123 + <LexiconErrorMessage error="Lexicon must have an 'id' field (e.g., 'com.example.myLexicon')" />, 101 124 ); 102 125 } 103 126 104 127 if (!lexiconData.defs && !lexiconData.definitions) { 105 128 return renderHTML( 106 - <LexiconErrorMessage error="Lexicon must have a 'defs' field containing the schema definitions" /> 129 + <LexiconErrorMessage error="Lexicon must have a 'defs' field containing the schema definitions" />, 107 130 ); 108 131 } 109 132 ··· 130 153 }; 131 154 132 155 const sliceClient = getSliceClient(context, sliceId); 133 - const result = await sliceClient.network.slices.lexicon.createRecord( 134 - lexiconRecord 156 + await sliceClient.network.slices.lexicon.createRecord( 157 + lexiconRecord, 135 158 ); 136 159 137 - return renderHTML( 138 - <LexiconSuccessMessage 139 - nsid={lexiconRecord.nsid} 140 - uri={result.uri} 141 - sliceId={sliceId} 142 - /> 143 - ); 160 + // Get the user's handle for the redirect 161 + const handle = context.currentUser?.handle; 162 + if (!handle) { 163 + throw new Error("Unable to determine user handle"); 164 + } 165 + 166 + // Redirect to the lexicons page using the utility 167 + const redirectUrl = buildSliceUrl(handle, sliceId, "lexicon"); 168 + return new Response("", { 169 + status: 200, 170 + headers: { 171 + "HX-Redirect": redirectUrl, 172 + }, 173 + }); 144 174 } catch (createError) { 145 175 let errorMessage = `Failed to create lexicon: ${createError}`; 146 176 ··· 175 205 } catch (error) { 176 206 return renderHTML( 177 207 <LexiconErrorMessage error={`Server error: ${error}`} />, 178 - { status: 500 } 179 208 ); 180 209 } 181 210 } 182 211 183 212 async function handleViewLexicon( 184 213 req: Request, 185 - params?: URLPatternResult 214 + params?: URLPatternResult, 186 215 ): Promise<Response> { 187 216 const authContext = await withAuth(req); 188 217 const sliceParams = extractSliceParams(params); ··· 195 224 const context = await withSliceAccess( 196 225 authContext, 197 226 sliceParams.handle, 198 - sliceParams.sliceId 227 + sliceParams.sliceId, 199 228 ); 200 - const accessError = requireSliceAccess(context); 201 - if (accessError) return accessError; 229 + 230 + // Check if slice exists (lexicon detail page is public) 231 + if (!context.sliceContext?.slice) { 232 + return new Response("Slice not found", { status: 404 }); 233 + } 202 234 203 235 try { 204 - const sliceClient = getSliceClient(authContext, sliceParams.sliceId); 236 + const sliceClient = getSliceClient(authContext, sliceParams.sliceId, context.sliceContext.profileDid); 205 237 206 - const lexiconRecords = 207 - await sliceClient.network.slices.lexicon.getRecords(); 238 + const lexiconRecords = await sliceClient.network.slices.lexicon 239 + .getRecords(); 208 240 209 241 const lexicon = lexiconRecords.records.find((record) => 210 242 record.uri.endsWith(`/${lexiconRkey}`) ··· 215 247 } 216 248 217 249 return renderHTML( 218 - <LexiconDetailPage 219 - slice={context.sliceContext!.slice!} 220 - sliceId={sliceParams.sliceId} 221 - nsid={lexicon.value.nsid} 222 - definitions={lexicon.value.definitions} 223 - uri={lexicon.uri} 224 - createdAt={lexicon.value.createdAt} 225 - currentUser={authContext.currentUser} 226 - /> 250 + await LexiconDetailPage({ 251 + slice: context.sliceContext!.slice!, 252 + sliceId: sliceParams.sliceId, 253 + nsid: lexicon.value.nsid, 254 + definitions: lexicon.value.definitions, 255 + uri: lexicon.uri, 256 + createdAt: lexicon.value.createdAt, 257 + currentUser: authContext.currentUser, 258 + hasSliceAccess: context.sliceContext?.hasAccess, 259 + }), 227 260 ); 228 261 } catch (error) { 229 262 console.error("Error viewing lexicon:", error); ··· 233 266 234 267 async function handleDeleteLexicon( 235 268 req: Request, 236 - params?: URLPatternResult 269 + params?: URLPatternResult, 237 270 ): Promise<Response> { 238 271 const context = await withAuth(req); 239 272 const authResponse = requireAuth(context); ··· 249 282 const sliceClient = getSliceClient(context, sliceId); 250 283 await sliceClient.network.slices.lexicon.deleteRecord(rkey); 251 284 252 - const remainingLexicons = 253 - await sliceClient.network.slices.lexicon.getRecords(); 285 + const remainingLexicons = await sliceClient.network.slices.lexicon 286 + .getRecords(); 254 287 255 288 if (remainingLexicons.records.length === 0) { 256 289 return renderHTML( ··· 264 297 headers: { 265 298 "HX-Retarget": "#lexicon-list", 266 299 }, 267 - } 300 + }, 268 301 ); 269 302 } else { 270 303 return new Response("", { ··· 278 311 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 279 312 <p>Failed to delete lexicon: {error}</p> 280 313 </div>, 281 - { status: 500 } 314 + { status: 500 }, 282 315 ); 283 316 } 284 317 } 285 318 286 319 async function handleToggleAllLexicons( 287 320 req: Request, 288 - params?: URLPatternResult 321 + params?: URLPatternResult, 289 322 ): Promise<Response> { 290 323 const context = await withAuth(req); 291 324 const authResponse = requireAuth(context); ··· 302 335 303 336 try { 304 337 const sliceClient = getSliceClient(context, sliceId); 305 - const lexiconRecords = 306 - await sliceClient.network.slices.lexicon.getRecords(); 338 + const lexiconRecords = await sliceClient.network.slices.lexicon 339 + .getRecords(); 307 340 308 341 if (lexiconRecords.records.length === 0) { 309 342 return renderHTML( ··· 312 345 title="No lexicons uploaded" 313 346 description="Upload lexicon definitions to define custom schemas for this slice." 314 347 withPadding 315 - /> 348 + />, 316 349 ); 317 350 } 318 351 352 + // Get slice info for domain comparison 353 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 354 + const sliceResponse = await sliceClient.network.slices.slice.getRecord({ 355 + uri: sliceUri, 356 + }); 357 + const sliceDomain = sliceResponse.value.domain; 358 + 319 359 return renderHTML( 320 360 <LexiconsList 321 361 records={lexiconRecords.records} 322 362 sliceId={sliceId} 323 363 handle={handle || undefined} 324 - /> 364 + sliceDomain={sliceDomain} 365 + hasSliceAccess 366 + />, 325 367 ); 326 368 } catch (error) { 327 369 console.error("Failed to toggle lexicons:", error); ··· 329 371 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 330 372 <p>Failed to load lexicons: {error}</p> 331 373 </div>, 332 - { status: 500 } 374 + { status: 500 }, 333 375 ); 334 376 } 335 377 } 336 378 337 379 async function handleBulkDeleteLexicons( 338 380 req: Request, 339 - params?: URLPatternResult 381 + params?: URLPatternResult, 340 382 ): Promise<Response> { 341 383 const context = await withAuth(req); 342 384 const authResponse = requireAuth(context); ··· 367 409 } 368 410 369 411 // Get remaining lexicons 370 - const remainingLexicons = 371 - await sliceClient.network.slices.lexicon.getRecords(); 412 + const remainingLexicons = await sliceClient.network.slices.lexicon 413 + .getRecords(); 372 414 373 415 if (remainingLexicons.records.length === 0) { 374 416 return renderHTML( ··· 377 419 title="No lexicons uploaded" 378 420 description="Upload lexicon definitions to define custom schemas for this slice." 379 421 withPadding 380 - /> 422 + />, 381 423 ); 382 424 } else { 425 + // Get slice info for domain comparison 426 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 427 + const sliceResponse = await sliceClient.network.slices.slice.getRecord({ 428 + uri: sliceUri, 429 + }); 430 + const sliceDomain = sliceResponse.value.domain; 431 + 383 432 return renderHTML( 384 433 <LexiconsList 385 434 records={remainingLexicons.records} 386 435 sliceId={sliceId} 387 436 handle={handle || undefined} 388 - /> 437 + sliceDomain={sliceDomain} 438 + hasSliceAccess 439 + />, 389 440 ); 390 441 } 391 442 } catch (error) { ··· 394 445 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 395 446 <p>Failed to delete lexicons: {error}</p> 396 447 </div>, 397 - { status: 500 } 448 + { status: 500 }, 398 449 ); 399 450 } 400 451 } 401 452 402 453 async function handleSliceLexiconPage( 403 454 req: Request, 404 - params?: URLPatternResult 455 + params?: URLPatternResult, 405 456 ): Promise<Response> { 406 457 const authContext = await withAuth(req); 407 458 const sliceParams = extractSliceParams(params); ··· 413 464 const context = await withSliceAccess( 414 465 authContext, 415 466 sliceParams.handle, 416 - sliceParams.sliceId 467 + sliceParams.sliceId, 417 468 ); 418 - const accessError = requireSliceAccess(context); 419 - if (accessError) return accessError; 469 + 470 + // Check if slice exists (lexicons page is public) 471 + if (!context.sliceContext?.slice) { 472 + return new Response("Slice not found", { status: 404 }); 473 + } 420 474 421 475 return renderHTML( 422 476 <SliceLexiconPage 423 477 slice={context.sliceContext!.slice!} 424 478 sliceId={sliceParams.sliceId} 425 479 currentUser={authContext.currentUser} 426 - /> 480 + hasSliceAccess={context.sliceContext?.hasAccess} 481 + />, 427 482 ); 428 483 } 429 484 485 + async function handleShowLexiconModal( 486 + req: Request, 487 + params?: URLPatternResult, 488 + ): Promise<Response> { 489 + const context = await withAuth(req); 490 + const authResponse = requireAuth(context); 491 + if (authResponse) return authResponse; 492 + 493 + const sliceId = params?.pathname.groups.id; 494 + if (!sliceId) { 495 + return new Response("Invalid slice ID", { status: 400 }); 496 + } 497 + 498 + return renderHTML(<LexiconFormModal sliceId={sliceId} />); 499 + } 500 + 430 501 export const lexiconRoutes: Route[] = [ 431 502 { 432 503 method: "GET", ··· 439 510 method: "GET", 440 511 pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/list" }), 441 512 handler: handleListLexicons, 513 + }, 514 + { 515 + method: "GET", 516 + pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/modal" }), 517 + handler: handleShowLexiconModal, 442 518 }, 443 519 { 444 520 method: "POST",
+38 -7
frontend/src/features/slices/lexicon/templates/LexiconDetailPage.tsx
··· 1 1 import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 2 import { Breadcrumb } from "../../../../shared/fragments/Breadcrumb.tsx"; 3 3 import { PageHeader } from "../../../../shared/fragments/PageHeader.tsx"; 4 + import { Button } from "../../../../shared/fragments/Button.tsx"; 5 + import { Copy } from "lucide-preact"; 4 6 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 5 7 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 6 8 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 9 + import { codeToHtml } from "jsr:@shikijs/shiki"; 7 10 8 11 interface LexiconDetailPageProps { 9 12 slice: NetworkSlicesSliceDefsSliceView; ··· 13 16 uri: string; 14 17 createdAt: string; 15 18 currentUser?: AuthenticatedUser; 19 + hasSliceAccess?: boolean; 16 20 } 17 21 18 - export function LexiconDetailPage({ 22 + export async function LexiconDetailPage({ 19 23 slice, 20 24 sliceId, 21 25 nsid, 22 26 definitions, 23 27 currentUser, 28 + hasSliceAccess, 24 29 }: LexiconDetailPageProps) { 25 30 let parsedDefinitions; 26 31 try { ··· 29 34 parsedDefinitions = definitions; 30 35 } 31 36 37 + // Build the complete lexicon object 38 + const completeLexicon = { 39 + lexicon: 1, 40 + id: nsid, 41 + defs: parsedDefinitions, 42 + }; 43 + 44 + // Generate syntax highlighted HTML 45 + const lexiconJson = JSON.stringify(completeLexicon, null, 2); 46 + const highlightedCode = await codeToHtml(lexiconJson, { 47 + lang: "json", 48 + theme: "tokyo-night", 49 + }); 50 + 32 51 return ( 33 52 <Layout title={`${nsid} - ${slice.name}`} currentUser={currentUser}> 34 53 <div className="max-w-6xl mx-auto px-4 py-8"> ··· 37 56 label="Back to Lexicons" 38 57 /> 39 58 40 - <PageHeader title={nsid} /> 59 + <div className="flex items-center justify-between mb-6"> 60 + <h1 className="text-3xl font-bold text-zinc-900">{nsid}</h1> 61 + <Button 62 + variant="secondary" 63 + onClick={`navigator.clipboard.writeText(${ 64 + JSON.stringify(lexiconJson) 65 + })`} 66 + > 67 + <span className="flex items-center gap-2"> 68 + <Copy size={16} /> 69 + Copy JSON 70 + </span> 71 + </Button> 72 + </div> 41 73 42 74 <div className="bg-white border border-zinc-200"> 43 75 <div className="px-6 py-4 border-b border-zinc-200"> ··· 46 78 </h2> 47 79 </div> 48 80 49 - <div className="bg-zinc-50 p-4 overflow-auto"> 50 - <pre className="text-sm font-mono whitespace-pre-wrap"> 51 - {JSON.stringify(parsedDefinitions, null, 2)} 52 - </pre> 53 - </div> 81 + <div 82 + className="text-sm overflow-x-auto [&_pre]:p-4" 83 + dangerouslySetInnerHTML={{ __html: highlightedCode }} 84 + /> 54 85 </div> 55 86 </div> 56 87 </Layout>
+32 -82
frontend/src/features/slices/lexicon/templates/SliceLexiconPage.tsx
··· 1 1 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 2 2 import { EmptyState } from "../../../../shared/fragments/EmptyState.tsx"; 3 3 import { Button } from "../../../../shared/fragments/Button.tsx"; 4 - import { Textarea } from "../../../../shared/fragments/Textarea.tsx"; 5 - import { FileCode } from "lucide-preact"; 4 + import { FileCode, Plus } from "lucide-preact"; 6 5 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 7 6 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 8 7 ··· 10 9 slice: NetworkSlicesSliceDefsSliceView; 11 10 sliceId: string; 12 11 currentUser?: AuthenticatedUser; 12 + hasSliceAccess?: boolean; 13 13 } 14 14 15 15 export function SliceLexiconPage({ 16 16 slice, 17 17 sliceId, 18 18 currentUser, 19 + hasSliceAccess, 19 20 }: SliceLexiconPageProps) { 20 21 return ( 21 22 <SlicePage ··· 23 24 sliceId={sliceId} 24 25 currentTab="lexicon" 25 26 currentUser={currentUser} 27 + hasSliceAccess={hasSliceAccess} 26 28 title={`${slice.name} - Lexicons`} 27 29 > 28 - 29 - <div className="bg-white border border-zinc-200 p-6 mb-6"> 30 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 31 - Add Lexicon Definition 30 + <div className="bg-white border border-zinc-200"> 31 + <div className="px-6 py-4 border-b border-zinc-200 flex items-center justify-between"> 32 + <h2 className="text-lg font-semibold text-zinc-900"> 33 + Slice Lexicons 32 34 </h2> 33 - <p className="text-zinc-600 mb-6"> 34 - Paste lexicon JSON to define custom record types for this slice. 35 - </p> 36 - 37 - <form 38 - hx-post={`/api/slices/${sliceId}/lexicons`} 39 - hx-target="#lexicon-result" 40 - hx-swap="innerHTML" 41 - hx-on="htmx:afterRequest: if(event.detail.successful) this.reset()" 42 - className="space-y-4" 43 - > 44 - <div> 45 - <Textarea 46 - label="Lexicon JSON" 47 - name="lexicon_json" 48 - rows={12} 49 - class="font-mono text-sm" 50 - placeholder={`{ 51 - "lexicon": 1, 52 - "id": "network.slices.example", 53 - "description": "Example record type", 54 - "defs": { 55 - "main": { 56 - "type": "record", 57 - "key": "tid", 58 - "record": { 59 - "type": "object", 60 - "required": ["text", "createdAt"], 61 - "properties": { 62 - "text": { 63 - "type": "string", 64 - "maxLength": 300 65 - }, 66 - "createdAt": { 67 - "type": "string", 68 - "format": "datetime" 69 - } 70 - } 71 - } 72 - } 73 - } 74 - }`} 75 - required 76 - /> 77 - <p className="text-sm text-zinc-500 mt-1"> 78 - Paste a valid AT Protocol lexicon definition in JSON format 79 - </p> 80 - </div> 81 - 82 - <Button 83 - type="submit" 35 + {hasSliceAccess && ( 36 + <Button 84 37 variant="purple" 85 - hx-indicator="#lexicon-loading" 38 + hx-get={`/api/slices/${sliceId}/lexicons/modal`} 39 + hx-target="#modal-container" 40 + hx-swap="innerHTML" 86 41 > 87 - <span id="lexicon-loading" class="htmx-indicator">Adding...</span> 88 - <span class="default-text">Add Lexicon</span> 42 + <span className="flex items-center"> 43 + <Plus size={16} className="mr-1" /> 44 + Add Lexicon 45 + </span> 89 46 </Button> 90 - </form> 91 - 92 - <div id="lexicon-result" className="mt-4"></div> 47 + )} 48 + </div> 49 + <div 50 + id="lexicon-list" 51 + hx-get={`/api/slices/${sliceId}/lexicons/list?handle=${slice.creator?.handle}`} 52 + hx-trigger="load, refresh-lexicons from:body" 53 + > 54 + <EmptyState 55 + icon={<FileCode size={64} strokeWidth={1} />} 56 + title="No lexicons uploaded" 57 + description="Upload lexicon definitions to define custom schemas for this slice." 58 + withPadding 59 + /> 93 60 </div> 61 + </div> 94 62 95 - <div className="bg-white border border-zinc-200"> 96 - <div className="px-6 py-4 border-b border-zinc-200"> 97 - <h2 className="text-lg font-semibold text-zinc-900"> 98 - Slice Lexicons 99 - </h2> 100 - </div> 101 - <div 102 - id="lexicon-list" 103 - hx-get={`/api/slices/${sliceId}/lexicons/list?handle=${slice.creator?.handle}`} 104 - hx-trigger="load" 105 - > 106 - <EmptyState 107 - icon={<FileCode size={64} strokeWidth={1} />} 108 - title="No lexicons uploaded" 109 - description="Upload lexicon definitions to define custom schemas for this slice." 110 - withPadding 111 - /> 112 - </div> 113 - </div> 63 + <div id="modal-container"></div> 114 64 </SlicePage> 115 65 ); 116 66 }
+1 -1
frontend/src/features/slices/lexicon/templates/fragments/LexiconErrorMessage.tsx
··· 24 24 </div> 25 25 </div> 26 26 ); 27 - } 27 + }
+82
frontend/src/features/slices/lexicon/templates/fragments/LexiconFormModal.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + import { Textarea } from "../../../../../shared/fragments/Textarea.tsx"; 3 + import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 4 + 5 + interface LexiconFormModalProps { 6 + sliceId: string; 7 + } 8 + 9 + export function LexiconFormModal({ sliceId }: LexiconFormModalProps) { 10 + return ( 11 + <Modal 12 + title="Add Lexicon Definition" 13 + description="Paste lexicon JSON to define custom record types for this slice." 14 + > 15 + <form 16 + hx-post={`/api/slices/${sliceId}/lexicons`} 17 + hx-target="#lexicon-result" 18 + hx-swap="innerHTML" 19 + > 20 + <div className="space-y-4"> 21 + <div> 22 + <Textarea 23 + label="Lexicon JSON" 24 + name="lexicon_json" 25 + rows={16} 26 + class="font-mono text-sm" 27 + placeholder={`{ 28 + "lexicon": 1, 29 + "id": "network.slices.example", 30 + "description": "Example record type", 31 + "defs": { 32 + "main": { 33 + "type": "record", 34 + "key": "tid", 35 + "record": { 36 + "type": "object", 37 + "required": ["text", "createdAt"], 38 + "properties": { 39 + "text": { 40 + "type": "string", 41 + "maxLength": 300 42 + }, 43 + "createdAt": { 44 + "type": "string", 45 + "format": "datetime" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + }`} 52 + required 53 + /> 54 + <p className="text-sm text-zinc-500 mt-1"> 55 + Paste a valid AT Protocol lexicon definition in JSON format 56 + </p> 57 + </div> 58 + 59 + <div id="lexicon-result"></div> 60 + 61 + <div className="flex justify-end gap-3"> 62 + <Button 63 + type="button" 64 + variant="secondary" 65 + _="on click set #modal-container's innerHTML to ''" 66 + > 67 + Cancel 68 + </Button> 69 + <Button 70 + type="submit" 71 + variant="purple" 72 + hx-indicator="#lexicon-loading" 73 + > 74 + <span id="lexicon-loading" class="htmx-indicator">Adding...</span> 75 + <span class="default-text">Add Lexicon</span> 76 + </Button> 77 + </div> 78 + </div> 79 + </form> 80 + </Modal> 81 + ); 82 + }
+39 -14
frontend/src/features/slices/lexicon/templates/fragments/LexiconListItem.tsx
··· 1 1 import { getRkeyFromUri } from "../../../../../utils/at-uri.ts"; 2 2 import { ChevronRight } from "lucide-preact"; 3 3 import { buildSliceUrl } from "../../../../../utils/slice-params.ts"; 4 + import { cn } from "../../../../../utils/cn.ts"; 4 5 5 6 export function LexiconListItem({ 6 7 nsid, 7 8 uri, 8 9 sliceId, 9 10 handle, 11 + isPrimary, 12 + hasSliceAccess, 10 13 }: { 11 14 nsid: string; 12 15 uri: string; 13 16 sliceId: string; 14 17 handle?: string; 18 + isPrimary?: boolean; 19 + hasSliceAccess?: boolean; 15 20 }) { 16 21 const rkey = getRkeyFromUri(uri); 17 22 18 23 return ( 19 - <div className="flex items-center hover:bg-zinc-50 transition-colors" id={`lexicon-${rkey}`}> 20 - <div className="px-6 py-4"> 21 - <input 22 - type="checkbox" 23 - name="lexicon_rkey" 24 - value={rkey} 25 - /> 26 - </div> 24 + <div 25 + className="flex items-center hover:bg-zinc-50 transition-colors" 26 + id={`lexicon-${rkey}`} 27 + > 28 + {hasSliceAccess && ( 29 + <div className="px-6 py-4"> 30 + <input 31 + type="checkbox" 32 + name="lexicon_rkey" 33 + value={rkey} 34 + /> 35 + </div> 36 + )} 27 37 <a 28 - href={handle ? buildSliceUrl(handle, sliceId, `lexicons/${rkey}`) : `/slices/${sliceId}/lexicons/${rkey}`} 29 - className="flex-1 block pr-6 py-4" 38 + href={handle 39 + ? buildSliceUrl(handle, sliceId, `lexicons/${rkey}`) 40 + : `/slices/${sliceId}/lexicons/${rkey}`} 41 + className={cn("flex-1 block pr-6 py-4", !hasSliceAccess && "pl-6")} 30 42 > 31 43 <div className="flex justify-between items-center"> 32 44 <div> 33 - <h3 className="text-lg font-medium text-zinc-900"> 34 - {nsid} 35 - </h3> 45 + <div className="flex items-center gap-2"> 46 + <h3 className="text-lg font-medium text-zinc-900"> 47 + {nsid} 48 + </h3> 49 + {isPrimary !== undefined && ( 50 + <span 51 + className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${ 52 + isPrimary 53 + ? "bg-green-100 text-green-800" 54 + : "bg-blue-100 text-blue-800" 55 + }`} 56 + > 57 + {isPrimary ? "Primary" : "External"} 58 + </span> 59 + )} 60 + </div> 36 61 </div> 37 62 <div className="text-zinc-400"> 38 63 <ChevronRight size={20} /> ··· 41 66 </a> 42 67 </div> 43 68 ); 44 - } 69 + }
+1 -1
frontend/src/features/slices/lexicon/templates/fragments/LexiconSuccessMessage.tsx
··· 46 46 </div> 47 47 </div> 48 48 ); 49 - } 49 + }
+90 -40
frontend/src/features/slices/lexicon/templates/fragments/LexiconsList.tsx
··· 12 12 records: LexiconRecord[]; 13 13 sliceId: string; 14 14 handle?: string; 15 + sliceDomain?: string; 16 + hasSliceAccess?: boolean; 17 + } 18 + 19 + function organizeLexicons(records: LexiconRecord[], sliceDomain?: string) { 20 + const primaryLexicons: LexiconRecord[] = []; 21 + const externalLexicons: LexiconRecord[] = []; 22 + 23 + records.forEach((record) => { 24 + if (sliceDomain && record.value.nsid.startsWith(sliceDomain)) { 25 + primaryLexicons.push(record); 26 + } else { 27 + externalLexicons.push(record); 28 + } 29 + }); 30 + 31 + return { primaryLexicons, externalLexicons }; 15 32 } 16 33 17 - export function LexiconsList({ records, sliceId, handle }: LexiconsListProps) { 34 + export function LexiconsList( 35 + { records, sliceId, handle, sliceDomain, hasSliceAccess }: LexiconsListProps, 36 + ) { 37 + const { primaryLexicons, externalLexicons } = organizeLexicons( 38 + records, 39 + sliceDomain, 40 + ); 41 + 18 42 return ( 19 43 <div> 20 - {/* Bulk actions bar */} 21 - <div className="px-6 py-3 border-b border-zinc-200 flex items-center justify-between"> 22 - <div className="flex items-center"> 23 - <input 24 - type="checkbox" 25 - id="select-all" 26 - className="mr-3" 27 - _="on change 28 - set checkboxes to document.querySelectorAll('input[name=lexicon_rkey]') 29 - for cb in checkboxes 30 - set cb.checked to my.checked 31 - end 32 - " 33 - /> 34 - <label htmlFor="select-all" className="text-sm font-medium text-zinc-700"> 35 - Select All 36 - </label> 44 + {/* Bulk actions bar - only show for slice owners */} 45 + {hasSliceAccess && ( 46 + <div className="px-6 py-3 border-b border-zinc-200 flex items-center justify-between"> 47 + <div className="flex items-center"> 48 + <input 49 + type="checkbox" 50 + id="select-all" 51 + className="mr-3" 52 + _="on change 53 + set checkboxes to document.querySelectorAll('input[name=lexicon_rkey]') 54 + for cb in checkboxes 55 + set cb.checked to my.checked 56 + end 57 + " 58 + /> 59 + <label 60 + htmlFor="select-all" 61 + className="text-sm font-medium text-zinc-700" 62 + > 63 + Select All 64 + </label> 65 + </div> 66 + <button 67 + type="button" 68 + className="inline-flex items-center px-3 py-1.5 border border-zinc-300 text-xs font-medium rounded text-zinc-700 bg-white hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" 69 + hx-delete={`/api/slices/${sliceId}/lexicons/bulk`} 70 + hx-target="#lexicon-list" 71 + hx-swap="outerHTML" 72 + hx-include="input[name='lexicon_rkey']:checked" 73 + hx-confirm="Are you sure you want to delete the selected lexicons?" 74 + > 75 + <Trash2 size={14} className="mr-1" /> Delete Selected 76 + </button> 37 77 </div> 38 - <button 39 - type="button" 40 - className="inline-flex items-center px-3 py-1.5 border border-zinc-300 text-xs font-medium rounded text-zinc-700 bg-white hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" 41 - hx-delete={`/api/slices/${sliceId}/lexicons/bulk`} 42 - hx-target="#lexicon-list" 43 - hx-swap="outerHTML" 44 - hx-include="input[name='lexicon_rkey']:checked" 45 - hx-confirm="Are you sure you want to delete the selected lexicons?" 46 - > 47 - <Trash2 size={14} className="mr-1" /> Delete Selected 48 - </button> 49 - </div> 78 + )} 50 79 51 80 {/* List of lexicons */} 52 81 <div id="lexicon-list" className="divide-y divide-zinc-200"> 53 - {records.map((record) => ( 54 - <LexiconListItem 55 - key={record.uri} 56 - nsid={record.value.nsid} 57 - uri={record.uri} 58 - sliceId={sliceId} 59 - handle={handle} 60 - /> 61 - ))} 82 + {primaryLexicons.length > 0 && ( 83 + <> 84 + {primaryLexicons.map((record) => ( 85 + <LexiconListItem 86 + key={record.uri} 87 + nsid={record.value.nsid} 88 + uri={record.uri} 89 + sliceId={sliceId} 90 + handle={handle} 91 + isPrimary 92 + hasSliceAccess={hasSliceAccess} 93 + /> 94 + ))} 95 + </> 96 + )} 97 + {externalLexicons.length > 0 && ( 98 + <> 99 + {externalLexicons.map((record) => ( 100 + <LexiconListItem 101 + key={record.uri} 102 + nsid={record.value.nsid} 103 + uri={record.uri} 104 + sliceId={sliceId} 105 + handle={handle} 106 + isPrimary={false} 107 + hasSliceAccess={hasSliceAccess} 108 + /> 109 + ))} 110 + </> 111 + )} 62 112 </div> 63 113 </div> 64 114 ); 65 - } 115 + }
+13 -2
frontend/src/features/slices/mod.ts
··· 11 11 import { jetstreamRoutes } from "./jetstream/handlers.tsx"; 12 12 13 13 // Export individual route groups 14 - export { overviewRoutes, settingsRoutes, lexiconRoutes, recordsRoutes, codegenRoutes, oauthRoutes, apiDocsRoutes, syncRoutes, syncLogsRoutes, jetstreamRoutes }; 14 + export { 15 + apiDocsRoutes, 16 + codegenRoutes, 17 + jetstreamRoutes, 18 + lexiconRoutes, 19 + oauthRoutes, 20 + overviewRoutes, 21 + recordsRoutes, 22 + settingsRoutes, 23 + syncLogsRoutes, 24 + syncRoutes, 25 + }; 15 26 16 27 // Export consolidated routes array for easy import 17 28 export const sliceRoutes: Route[] = [ ··· 25 36 ...syncRoutes, 26 37 ...syncLogsRoutes, 27 38 ...jetstreamRoutes, 28 - ]; 39 + ];
+43 -30
frontend/src/features/slices/oauth/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 - import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 2 + import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 3 3 import { getSliceClient } from "../../../utils/client.ts"; 4 - import { buildSliceUri, buildAtUri } from "../../../utils/at-uri.ts"; 5 - import { withSliceAccess, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 6 - import { extractSliceParams } from "../../../utils/slice-params.ts"; 4 + import { buildAtUri, buildSliceUri } from "../../../utils/at-uri.ts"; 5 + import { 6 + requireSliceAccess, 7 + withSliceAccess, 8 + } from "../../../routes/slice-middleware.ts"; 9 + import { 10 + buildSliceUrl, 11 + extractSliceParams, 12 + } from "../../../utils/slice-params.ts"; 7 13 import { renderHTML } from "../../../utils/render.tsx"; 14 + import { hxRedirect } from "../../../utils/htmx.ts"; 8 15 import { SliceOAuthPage } from "./templates/SliceOAuthPage.tsx"; 9 16 import { OAuthClientModal } from "./templates/fragments/OAuthClientModal.tsx"; 10 17 import { OAuthRegistrationResult } from "./templates/fragments/OAuthRegistrationResult.tsx"; ··· 27 34 sliceUri={sliceUri} 28 35 mode="new" 29 36 clientData={undefined} 30 - /> 37 + />, 31 38 ); 32 39 } catch (error) { 33 40 console.error("Error showing new OAuth client modal:", error); ··· 46 53 Close 47 54 </button> 48 55 </div>, 49 - { status: 500 } 56 + { status: 500 }, 50 57 ); 51 58 } 52 59 } ··· 77 84 78 85 // Register new OAuth client via backend API 79 86 const sliceClient = getSliceClient(context, sliceId); 80 - const newClient = await sliceClient.network.slices.slice.createOAuthClient({ 87 + await sliceClient.network.slices.slice.createOAuthClient({ 81 88 clientName, 82 89 redirectUris, 83 90 scope: scope || undefined, ··· 87 94 policyUri: policyUri || undefined, 88 95 }); 89 96 90 - return renderHTML( 91 - <OAuthRegistrationResult 92 - success 93 - sliceId={sliceId} 94 - clientId={newClient.clientId} 95 - /> 96 - ); 97 + // Get the user's handle for the redirect 98 + const handle = context.currentUser?.handle; 99 + if (!handle) { 100 + throw new Error("Unable to determine user handle"); 101 + } 102 + 103 + // Redirect to the OAuth page to show the new client 104 + const redirectUrl = buildSliceUrl(handle, sliceId, "oauth"); 105 + return hxRedirect(redirectUrl); 97 106 } catch (error) { 98 107 console.error("Error registering OAuth client:", error); 99 108 return renderHTML( ··· 102 111 error={error instanceof Error ? error.message : String(error)} 103 112 sliceId={sliceId} 104 113 />, 105 - { status: 500 } 114 + { status: 500 }, 106 115 ); 107 116 } 108 117 } ··· 130 139 success={false} 131 140 error={error instanceof Error ? error.message : String(error)} 132 141 />, 133 - { status: 500 } 142 + { status: 500 }, 134 143 ); 135 144 } 136 145 } ··· 148 157 try { 149 158 // Fetch OAuth client details via backend API 150 159 const sliceClient = getSliceClient(context, sliceId); 151 - const clientsResponse = 152 - await sliceClient.network.slices.slice.getOAuthClients(); 160 + const clientsResponse = await sliceClient.network.slices.slice 161 + .getOAuthClients(); 153 162 const clientData = clientsResponse.clients.find( 154 - (c) => c.clientId === clientId 163 + (c) => c.clientId === clientId, 155 164 ); 156 165 157 166 const sliceUri = buildAtUri({ ··· 166 175 sliceUri={sliceUri} 167 176 mode="view" 168 177 clientData={clientData} 169 - /> 178 + />, 170 179 ); 171 180 } catch (error) { 172 181 console.error("Error fetching OAuth client:", error); ··· 185 194 Close 186 195 </button> 187 196 </div>, 188 - { status: 500 } 197 + { status: 500 }, 189 198 ); 190 199 } 191 200 } ··· 218 227 219 228 // Update OAuth client via backend API 220 229 const sliceClient = getSliceClient(context, sliceId); 221 - const updatedClient = 222 - await sliceClient.network.slices.slice.updateOAuthClient({ 230 + const updatedClient = await sliceClient.network.slices.slice 231 + .updateOAuthClient({ 223 232 clientId, 224 233 clientName: clientName || undefined, 225 234 redirectUris: redirectUris.length > 0 ? redirectUris : undefined, ··· 237 246 sliceUri={sliceUri} 238 247 mode="view" 239 248 clientData={updatedClient} 240 - /> 249 + />, 241 250 ); 242 251 } catch (error) { 243 252 console.error("Error updating OAuth client:", error); ··· 246 255 success={false} 247 256 error={error instanceof Error ? error.message : String(error)} 248 257 />, 249 - { status: 500 } 258 + { status: 500 }, 250 259 ); 251 260 } 252 261 } 253 262 254 263 async function handleSliceOAuthPage( 255 264 req: Request, 256 - params?: URLPatternResult 265 + params?: URLPatternResult, 257 266 ): Promise<Response> { 258 267 const authContext = await withAuth(req); 259 268 const sliceParams = extractSliceParams(params); ··· 262 271 return new Response("Invalid slice ID", { status: 400 }); 263 272 } 264 273 265 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 274 + const context = await withSliceAccess( 275 + authContext, 276 + sliceParams.handle, 277 + sliceParams.sliceId, 278 + ); 266 279 const accessError = requireSliceAccess(context); 267 280 if (accessError) return accessError; 268 281 ··· 278 291 let errorMessage = null; 279 292 280 293 try { 281 - const oauthClientsResponse = 282 - await sliceClient.network.slices.slice.getOAuthClients(); 294 + const oauthClientsResponse = await sliceClient.network.slices.slice 295 + .getOAuthClients(); 283 296 clientsWithDetails = oauthClientsResponse.clients.map((client) => ({ 284 297 clientId: client.clientId, 285 298 createdAt: new Date().toISOString(), // Backend should provide this ··· 298 311 clients={clientsWithDetails} 299 312 currentUser={authContext.currentUser} 300 313 error={errorMessage} 301 - /> 314 + />, 302 315 ); 303 316 } 304 317
+20 -20
frontend/src/features/slices/oauth/templates/SliceOAuthPage.tsx
··· 68 68 </div> 69 69 </div> 70 70 71 - {clients.length === 0 ? ( 72 - <EmptyState 73 - icon={<Key size={64} strokeWidth={1} />} 74 - title="No OAuth clients registered" 75 - description="Register OAuth clients to allow applications to access your slice data." 76 - withPadding 77 - > 78 - <Button 79 - type="button" 80 - variant="primary" 81 - hx-get={`/api/slices/${sliceId}/oauth/new`} 82 - hx-target="#modal-container" 83 - hx-swap="innerHTML" 71 + {clients.length === 0 72 + ? ( 73 + <EmptyState 74 + icon={<Key size={64} strokeWidth={1} />} 75 + title="No OAuth clients registered" 76 + description="Register OAuth clients to allow applications to access your slice data." 77 + withPadding 84 78 > 85 - Register Your First Client 86 - </Button> 87 - </EmptyState> 88 - ) : ( 89 - <OAuthClientsList clients={clients} sliceId={sliceId} /> 90 - )} 79 + <Button 80 + type="button" 81 + variant="primary" 82 + hx-get={`/api/slices/${sliceId}/oauth/new`} 83 + hx-target="#modal-container" 84 + hx-swap="innerHTML" 85 + > 86 + Register Your First Client 87 + </Button> 88 + </EmptyState> 89 + ) 90 + : <OAuthClientsList clients={clients} sliceId={sliceId} />} 91 91 </div> 92 92 93 93 <div id="modal-container"></div> 94 94 </SlicePage> 95 95 ); 96 - } 96 + }
+123 -144
frontend/src/features/slices/oauth/templates/fragments/OAuthClientModal.tsx
··· 2 2 import { Button } from "../../../../../shared/fragments/Button.tsx"; 3 3 import { Input } from "../../../../../shared/fragments/Input.tsx"; 4 4 import { Textarea } from "../../../../../shared/fragments/Textarea.tsx"; 5 + import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 5 6 6 7 interface OAuthClientModalProps { 7 8 sliceId: string; ··· 18 19 }: OAuthClientModalProps) { 19 20 if (mode === "view" && clientData) { 20 21 return ( 21 - <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 22 - <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 23 - <form 24 - hx-post={`/api/slices/${sliceId}/oauth/${encodeURIComponent(clientData.clientId)}/update`} 25 - hx-target="#modal-container" 26 - hx-swap="outerHTML" 27 - > 28 - <div className="flex justify-between items-start mb-4"> 29 - <h2 className="text-2xl font-semibold">OAuth Client Details</h2> 30 - <button 31 - type="button" 32 - _="on click set #modal-container's innerHTML to ''" 33 - className="text-gray-400 hover:text-gray-600" 34 - > 35 - 36 - </button> 22 + <Modal title="OAuth Client Details"> 23 + <form 24 + hx-post={`/api/slices/${sliceId}/oauth/${ 25 + encodeURIComponent(clientData.clientId) 26 + }/update`} 27 + hx-target="#modal-container" 28 + hx-swap="outerHTML" 29 + > 30 + <div className="space-y-4"> 31 + <div> 32 + <label className="block text-sm font-medium text-gray-700 mb-1"> 33 + Client ID 34 + </label> 35 + <div className="font-mono text-sm bg-gray-100 p-2 rounded border"> 36 + {clientData.clientId} 37 + </div> 37 38 </div> 38 39 39 - <div className="space-y-4"> 40 + {clientData.clientSecret && ( 40 41 <div> 41 42 <label className="block text-sm font-medium text-gray-700 mb-1"> 42 - Client ID 43 + Client Secret 43 44 </label> 44 - <div className="font-mono text-sm bg-gray-100 p-2 rounded border"> 45 - {clientData.clientId} 46 - </div> 47 - </div> 48 - 49 - {clientData.clientSecret && ( 50 - <div> 51 - <label className="block text-sm font-medium text-gray-700 mb-1"> 52 - Client Secret 53 - </label> 54 - <div className="font-mono text-sm bg-yellow-50 border border-yellow-200 p-2 rounded"> 55 - <div className="text-yellow-800 text-xs mb-1">⚠️ Save this secret - it won't be shown again</div> 56 - {clientData.clientSecret} 45 + <div className="font-mono text-sm bg-yellow-50 border border-yellow-200 p-2 rounded"> 46 + <div className="text-yellow-800 text-xs mb-1"> 47 + ⚠️ Save this secret - it won't be shown again 57 48 </div> 49 + {clientData.clientSecret} 58 50 </div> 59 - )} 60 - 61 - <Input 62 - id="clientName" 63 - name="clientName" 64 - label="Client Name" 65 - required 66 - defaultValue={clientData.clientName} 67 - /> 68 - 69 - <div> 70 - <Textarea 71 - id="redirectUris" 72 - name="redirectUris" 73 - label="Redirect URIs" 74 - required 75 - rows={3} 76 - defaultValue={clientData.redirectUris.join('\n')} 77 - /> 78 - <p className="text-sm text-gray-500 mt-1"> 79 - Enter one redirect URI per line 80 - </p> 81 51 </div> 82 - 83 - <Input 84 - id="scope" 85 - name="scope" 86 - label="Scope" 87 - defaultValue={clientData.scope || ''} 88 - placeholder="atproto:atproto" 89 - /> 52 + )} 90 53 91 - <Input 92 - type="url" 93 - id="clientUri" 94 - name="clientUri" 95 - label="Client URI" 96 - defaultValue={clientData.clientUri || ''} 97 - placeholder="https://example.com" 98 - /> 99 - 100 - <Input 101 - type="url" 102 - id="logoUri" 103 - name="logoUri" 104 - label="Logo URI" 105 - defaultValue={clientData.logoUri || ''} 106 - placeholder="https://example.com/logo.png" 107 - /> 108 - 109 - <Input 110 - type="url" 111 - id="tosUri" 112 - name="tosUri" 113 - label="Terms of Service URI" 114 - defaultValue={clientData.tosUri || ''} 115 - placeholder="https://example.com/terms" 116 - /> 117 - 118 - <Input 119 - type="url" 120 - id="policyUri" 121 - name="policyUri" 122 - label="Privacy Policy URI" 123 - defaultValue={clientData.policyUri || ''} 124 - placeholder="https://example.com/privacy" 125 - /> 126 - 127 - <div className="flex justify-end gap-3 mt-6"> 128 - <Button 129 - type="button" 130 - variant="secondary" 131 - _="on click set #modal-container's innerHTML to ''" 132 - > 133 - Cancel 134 - </Button> 135 - <Button type="submit" variant="primary"> 136 - Update Client 137 - </Button> 138 - </div> 139 - </div> 140 - </form> 141 - </div> 142 - </div> 143 - ); 144 - } 145 - 146 - return ( 147 - <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 148 - <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 149 - <form 150 - hx-post={`/api/slices/${sliceId}/oauth/register`} 151 - hx-target="#modal-container" 152 - hx-swap="outerHTML" 153 - > 154 - <input type="hidden" name="sliceUri" value={sliceUri} /> 155 - 156 - <div className="flex justify-between items-start mb-4"> 157 - <h2 className="text-2xl font-semibold">Register OAuth Client</h2> 158 - <button 159 - type="button" 160 - _="on click set #modal-container's innerHTML to ''" 161 - className="text-gray-400 hover:text-gray-600" 162 - > 163 - 164 - </button> 165 - </div> 166 - 167 - <div className="space-y-4"> 168 54 <Input 169 55 id="clientName" 170 56 name="clientName" 171 57 label="Client Name" 172 58 required 173 - placeholder="My Application" 59 + defaultValue={clientData.clientName} 174 60 /> 175 61 176 62 <div> ··· 180 66 label="Redirect URIs" 181 67 required 182 68 rows={3} 183 - placeholder="https://example.com/callback&#10;https://localhost:3000/callback" 69 + defaultValue={clientData.redirectUris.join("\n")} 184 70 /> 185 71 <p className="text-sm text-gray-500 mt-1"> 186 72 Enter one redirect URI per line ··· 191 77 id="scope" 192 78 name="scope" 193 79 label="Scope" 80 + defaultValue={clientData.scope || ""} 194 81 placeholder="atproto:atproto" 195 82 /> 196 83 ··· 199 86 id="clientUri" 200 87 name="clientUri" 201 88 label="Client URI" 89 + defaultValue={clientData.clientUri || ""} 202 90 placeholder="https://example.com" 203 91 /> 204 92 ··· 207 95 id="logoUri" 208 96 name="logoUri" 209 97 label="Logo URI" 98 + defaultValue={clientData.logoUri || ""} 210 99 placeholder="https://example.com/logo.png" 211 100 /> 212 101 ··· 215 104 id="tosUri" 216 105 name="tosUri" 217 106 label="Terms of Service URI" 107 + defaultValue={clientData.tosUri || ""} 218 108 placeholder="https://example.com/terms" 219 109 /> 220 110 ··· 223 113 id="policyUri" 224 114 name="policyUri" 225 115 label="Privacy Policy URI" 116 + defaultValue={clientData.policyUri || ""} 226 117 placeholder="https://example.com/privacy" 227 118 /> 228 119 ··· 235 126 Cancel 236 127 </Button> 237 128 <Button type="submit" variant="primary"> 238 - Register Client 129 + Update Client 239 130 </Button> 240 131 </div> 241 132 </div> 242 133 </form> 243 - </div> 244 - </div> 134 + </Modal> 135 + ); 136 + } 137 + 138 + return ( 139 + <Modal title="Register OAuth Client"> 140 + <form 141 + hx-post={`/api/slices/${sliceId}/oauth/register`} 142 + hx-target="#modal-container" 143 + hx-swap="outerHTML" 144 + > 145 + <input type="hidden" name="sliceUri" value={sliceUri} /> 146 + 147 + <div className="space-y-4"> 148 + <Input 149 + id="clientName" 150 + name="clientName" 151 + label="Client Name" 152 + required 153 + placeholder="My Application" 154 + /> 155 + 156 + <div> 157 + <Textarea 158 + id="redirectUris" 159 + name="redirectUris" 160 + label="Redirect URIs" 161 + required 162 + rows={3} 163 + placeholder="https://example.com/callback&#10;https://localhost:3000/callback" 164 + /> 165 + <p className="text-sm text-gray-500 mt-1"> 166 + Enter one redirect URI per line 167 + </p> 168 + </div> 169 + 170 + <Input 171 + id="scope" 172 + name="scope" 173 + label="Scope" 174 + placeholder="atproto:atproto" 175 + /> 176 + 177 + <Input 178 + type="url" 179 + id="clientUri" 180 + name="clientUri" 181 + label="Client URI" 182 + placeholder="https://example.com" 183 + /> 184 + 185 + <Input 186 + type="url" 187 + id="logoUri" 188 + name="logoUri" 189 + label="Logo URI" 190 + placeholder="https://example.com/logo.png" 191 + /> 192 + 193 + <Input 194 + type="url" 195 + id="tosUri" 196 + name="tosUri" 197 + label="Terms of Service URI" 198 + placeholder="https://example.com/terms" 199 + /> 200 + 201 + <Input 202 + type="url" 203 + id="policyUri" 204 + name="policyUri" 205 + label="Privacy Policy URI" 206 + placeholder="https://example.com/privacy" 207 + /> 208 + 209 + <div className="flex justify-end gap-3 mt-6"> 210 + <Button 211 + type="button" 212 + variant="secondary" 213 + _="on click set #modal-container's innerHTML to ''" 214 + > 215 + Cancel 216 + </Button> 217 + <Button type="submit" variant="primary"> 218 + Register Client 219 + </Button> 220 + </div> 221 + </div> 222 + </form> 223 + </Modal> 245 224 ); 246 - } 225 + }
+15 -8
frontend/src/features/slices/oauth/templates/fragments/OAuthClientsList.tsx
··· 16 16 return ( 17 17 <div className="divide-y divide-zinc-200"> 18 18 {clients.map((client) => ( 19 - <div key={client.clientId} className="oauth-client-item px-6 py-4 hover:bg-zinc-50 transition-colors"> 19 + <div 20 + key={client.clientId} 21 + className="oauth-client-item px-6 py-4 hover:bg-zinc-50 transition-colors" 22 + > 20 23 <div className="flex justify-between items-start"> 21 24 <div className="flex-1"> 22 25 <div className="flex items-center gap-3 mb-2"> ··· 49 52 type="button" 50 53 variant="ghost" 51 54 size="sm" 52 - hx-get={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 53 - client.clientId 54 - )}/view`} 55 + hx-get={`/api/slices/${sliceId}/oauth/${ 56 + encodeURIComponent( 57 + client.clientId, 58 + ) 59 + }/view`} 55 60 hx-target="#modal-container" 56 61 hx-swap="innerHTML" 57 62 className="text-purple-600 hover:text-purple-800" ··· 62 67 type="button" 63 68 variant="ghost" 64 69 size="sm" 65 - hx-delete={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 66 - client.clientId 67 - )}`} 70 + hx-delete={`/api/slices/${sliceId}/oauth/${ 71 + encodeURIComponent( 72 + client.clientId, 73 + ) 74 + }`} 68 75 hx-confirm="Are you sure you want to delete this OAuth client?" 69 76 hx-target="closest .oauth-client-item" 70 77 hx-swap="outerHTML" ··· 78 85 ))} 79 86 </div> 80 87 ); 81 - } 88 + }
+1 -1
frontend/src/features/slices/oauth/templates/fragments/OAuthDeleteResult.tsx
··· 15 15 </td> 16 16 </tr> 17 17 ); 18 - } 18 + }
+1 -1
frontend/src/features/slices/oauth/templates/fragments/OAuthRegistrationResult.tsx
··· 67 67 </div> 68 68 </div> 69 69 ); 70 - } 70 + }
+17 -6
frontend/src/features/slices/overview/handlers.tsx
··· 2 2 import { withAuth } from "../../../routes/middleware.ts"; 3 3 import { renderHTML } from "../../../utils/render.tsx"; 4 4 import { SliceOverview } from "./templates/SliceOverview.tsx"; 5 - import { withSliceAccess, getSliceStats, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 5 + import { 6 + getSliceStats, 7 + withSliceAccess, 8 + } from "../../../routes/slice-middleware.ts"; 6 9 import { extractSliceParams } from "../../../utils/slice-params.ts"; 7 10 8 11 async function handleSliceOverview( 9 12 req: Request, 10 - params?: URLPatternResult 13 + params?: URLPatternResult, 11 14 ): Promise<Response> { 12 15 const authContext = await withAuth(req); 13 16 const sliceParams = extractSliceParams(params); ··· 16 19 return Response.redirect(new URL("/", req.url), 302); 17 20 } 18 21 19 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 20 - const accessError = requireSliceAccess(context); 21 - if (accessError) return accessError; 22 + const context = await withSliceAccess( 23 + authContext, 24 + sliceParams.handle, 25 + sliceParams.sliceId, 26 + ); 27 + 28 + // Check if slice exists (not if user has access - overview is public) 29 + if (!context.sliceContext?.slice) { 30 + return new Response("Slice not found", { status: 404 }); 31 + } 22 32 23 33 const stats = await getSliceStats(context.sliceContext!.sliceUri); 24 34 ··· 32 42 collections={stats.collections} 33 43 currentTab="overview" 34 44 currentUser={authContext.currentUser} 35 - /> 45 + hasSliceAccess={context.sliceContext?.hasAccess} 46 + />, 36 47 ); 37 48 } 38 49
+96 -64
frontend/src/features/slices/overview/templates/SliceOverview.tsx
··· 19 19 collections?: Collection[]; 20 20 currentTab?: string; 21 21 currentUser?: AuthenticatedUser; 22 + hasSliceAccess?: boolean; 22 23 } 23 24 24 25 export function SliceOverview({ ··· 29 30 collections = [], 30 31 currentTab = "overview", 31 32 currentUser, 33 + hasSliceAccess, 32 34 }: SliceOverviewProps) { 33 35 return ( 34 36 <SlicePage ··· 36 38 sliceId={sliceId} 37 39 currentTab={currentTab} 38 40 currentUser={currentUser} 41 + hasSliceAccess={hasSliceAccess} 39 42 > 40 43 <div 41 44 hx-get={`/api/jetstream/status?sliceId=${sliceId}&handle=${slice.creator?.handle}`} ··· 76 79 </div> 77 80 <div> 78 81 <span className="text-2xl font-bold">{totalActors}</span> 79 - <p className="text-sm">Unique Actors</p> 82 + <p className="text-sm">Actors</p> 80 83 </div> 81 84 </div> 82 85 </div> ··· 90 93 <p className="text-zinc-600 mb-4"> 91 94 View lexicon definitions and schemas that define your slice. 92 95 </p> 93 - <Button href={buildSliceUrlFromView(slice, sliceId, "lexicon")} variant="purple"> 96 + <Button 97 + href={buildSliceUrlFromView(slice, sliceId, "lexicon")} 98 + variant="purple" 99 + > 94 100 View Lexicons 95 101 </Button> 96 102 </div> ··· 102 108 <p className="text-zinc-600 mb-4"> 103 109 Browse indexed AT Protocol records by collection. 104 110 </p> 105 - {collections.length > 0 ? ( 106 - <Button href={buildSliceUrlFromView(slice, sliceId, "records")} variant="primary"> 107 - Browse Records 108 - </Button> 109 - ) : ( 110 - <p className="text-zinc-500 text-sm"> 111 - No records synced yet. Start by syncing some records! 112 - </p> 113 - )} 111 + {collections.length > 0 112 + ? ( 113 + <Button 114 + href={buildSliceUrlFromView(slice, sliceId, "records")} 115 + variant="primary" 116 + > 117 + Browse Records 118 + </Button> 119 + ) 120 + : ( 121 + <p className="text-zinc-500 text-sm"> 122 + No records synced yet. Start by syncing some records! 123 + </p> 124 + )} 114 125 </div> 115 126 116 127 <div className="bg-white border border-zinc-200 p-6"> ··· 120 131 <p className="text-zinc-600 mb-4"> 121 132 Generate TypeScript client from your lexicon definitions. 122 133 </p> 123 - <Button href={buildSliceUrlFromView(slice, sliceId, "codegen")} variant="warning"> 134 + <Button 135 + href={buildSliceUrlFromView(slice, sliceId, "codegen")} 136 + variant="warning" 137 + > 124 138 Generate Client 125 139 </Button> 126 140 </div> ··· 132 146 <p className="text-zinc-600 mb-4"> 133 147 Interactive OpenAPI documentation for your slice's XRPC endpoints. 134 148 </p> 135 - <Button href={buildSliceUrlFromView(slice, sliceId, "api-docs")} variant="indigo"> 149 + <Button 150 + href={buildSliceUrlFromView(slice, sliceId, "api-docs")} 151 + variant="indigo" 152 + > 136 153 View API Docs 137 154 </Button> 138 155 </div> 139 156 140 - <div className="bg-white border border-zinc-200 p-6"> 141 - <h2 className="text-xl font-semibold text-zinc-900 mb-4">🔄 Sync</h2> 142 - <p className="text-zinc-600 mb-4"> 143 - Sync entire collections from AT Protocol network. 144 - </p> 145 - <Button href={buildSliceUrlFromView(slice, sliceId, "sync")} variant="success"> 146 - Start Sync 147 - </Button> 148 - </div> 149 - 150 - {collections.length > 0 ? ( 157 + {hasSliceAccess && ( 151 158 <div className="bg-white border border-zinc-200 p-6"> 152 159 <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 153 - 📊 Synced Collections 160 + 🔄 Sync 154 161 </h2> 155 162 <p className="text-zinc-600 mb-4"> 156 - Collections currently indexed in the database. 163 + Sync entire collections from AT Protocol network. 157 164 </p> 158 - <div className="space-y-3 max-h-40 overflow-y-auto"> 159 - {collections.map((collection) => ( 160 - <div 161 - key={collection.name} 162 - className="border-b border-zinc-100 pb-2" 163 - > 164 - <a 165 - href={`${buildSliceUrlFromView(slice, sliceId, "records")}?collection=${collection.name}`} 166 - className="text-zinc-700 hover:text-zinc-900 hover:underline text-sm font-medium" 165 + <Button 166 + href={buildSliceUrlFromView(slice, sliceId, "sync")} 167 + variant="success" 168 + > 169 + Start Sync 170 + </Button> 171 + </div> 172 + )} 173 + 174 + {collections.length > 0 175 + ? ( 176 + <div className="bg-white border border-zinc-200 p-6"> 177 + <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 178 + 📊 Synced Collections 179 + </h2> 180 + <p className="text-zinc-600 mb-4"> 181 + Collections currently indexed in the database. 182 + </p> 183 + <div className="space-y-3 max-h-40 overflow-y-auto"> 184 + {collections.map((collection) => ( 185 + <div 186 + key={collection.name} 187 + className="border-b border-zinc-100 pb-2" 167 188 > 168 - {collection.name} 169 - </a> 170 - <div className="flex justify-between text-xs text-zinc-500 mt-1"> 171 - <span>{collection.count} records</span> 172 - {collection.actors && ( 173 - <span>{collection.actors} actors</span> 174 - )} 189 + <a 190 + href={`${ 191 + buildSliceUrlFromView( 192 + slice, 193 + sliceId, 194 + "records", 195 + ) 196 + }?collection=${collection.name}`} 197 + className="text-zinc-700 hover:text-zinc-900 hover:underline text-sm font-medium" 198 + > 199 + {collection.name} 200 + </a> 201 + <div className="flex justify-between text-xs text-zinc-500 mt-1"> 202 + <span>{collection.count} records</span> 203 + {collection.actors && ( 204 + <span>{collection.actors} actors</span> 205 + )} 206 + </div> 175 207 </div> 176 - </div> 177 - ))} 208 + ))} 209 + </div> 178 210 </div> 179 - </div> 180 - ) : ( 181 - <div className="bg-white border border-zinc-200 p-6"> 182 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 183 - 🌟 Get Started 184 - </h2> 185 - <p className="text-zinc-600 mb-4"> 186 - No records indexed yet. Start by syncing some AT Protocol 187 - collections! 188 - </p> 189 - <div className="space-y-2 text-sm"> 190 - <p className="text-zinc-500">Try syncing collections like:</p> 191 - <code className="block bg-zinc-100 p-2 rounded text-xs"> 192 - app.bsky.feed.post 193 - </code> 194 - <code className="block bg-zinc-100 p-2 rounded text-xs"> 195 - app.bsky.actor.profile 196 - </code> 211 + ) 212 + : ( 213 + <div className="bg-white border border-zinc-200 p-6"> 214 + <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 215 + 🌟 Get Started 216 + </h2> 217 + <p className="text-zinc-600 mb-4"> 218 + No records indexed yet. Start by syncing some AT Protocol 219 + collections! 220 + </p> 221 + <div className="space-y-2 text-sm"> 222 + <p className="text-zinc-500">Try syncing collections like:</p> 223 + <code className="block bg-zinc-100 p-2 rounded text-xs"> 224 + app.bsky.feed.post 225 + </code> 226 + <code className="block bg-zinc-100 p-2 rounded text-xs"> 227 + app.bsky.actor.profile 228 + </code> 229 + </div> 197 230 </div> 198 - </div> 199 - )} 231 + )} 200 232 </div> 201 233 </SlicePage> 202 234 );
+23 -10
frontend/src/features/slices/records/handlers.tsx
··· 3 3 import { getSliceClient } from "../../../utils/client.ts"; 4 4 import { renderHTML } from "../../../utils/render.tsx"; 5 5 import { SliceRecordsPage } from "./templates/SliceRecordsPage.tsx"; 6 - import { withSliceAccess, getSliceStats, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 6 + import { 7 + getSliceStats, 8 + withSliceAccess, 9 + } from "../../../routes/slice-middleware.ts"; 7 10 import { extractSliceParams } from "../../../utils/slice-params.ts"; 8 11 import type { IndexedRecord } from "../../../client.ts"; 9 12 10 13 async function handleSliceRecordsPage( 11 14 req: Request, 12 - params?: URLPatternResult 15 + params?: URLPatternResult, 13 16 ): Promise<Response> { 14 17 const authContext = await withAuth(req); 15 18 const sliceParams = extractSliceParams(params); ··· 18 21 return Response.redirect(new URL("/", req.url), 302); 19 22 } 20 23 21 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 22 - const accessError = requireSliceAccess(context); 23 - if (accessError) return accessError; 24 + const context = await withSliceAccess( 25 + authContext, 26 + sliceParams.handle, 27 + sliceParams.sliceId, 28 + ); 29 + 30 + // Check if slice exists (records page is public) 31 + if (!context.sliceContext?.slice) { 32 + return new Response("Slice not found", { status: 404 }); 33 + } 24 34 25 35 const stats = await getSliceStats(context.sliceContext!.sliceUri); 26 36 const collections = stats.collections.map((stat) => ({ ··· 39 49 40 50 if ((selectedCollection || searchQuery) && collections.length > 0) { 41 51 try { 42 - const sliceClient = getSliceClient(authContext, sliceParams.sliceId); 43 - const recordsResult = 44 - await sliceClient.network.slices.slice.getSliceRecords({ 52 + const sliceClient = getSliceClient(authContext, sliceParams.sliceId, context.sliceContext.profileDid); 53 + const recordsResult = await sliceClient.network.slices.slice 54 + .getSliceRecords({ 45 55 where: { 46 56 ...(selectedCollection && { 47 57 collection: { eq: selectedCollection }, ··· 78 88 search={searchQuery} 79 89 availableCollections={collections} 80 90 currentUser={authContext.currentUser} 81 - /> 91 + hasSliceAccess={context.sliceContext?.hasAccess} 92 + />, 82 93 ); 83 94 } 84 95 85 96 export const recordsRoutes: Route[] = [ 86 97 { 87 98 method: "GET", 88 - pattern: new URLPattern({ pathname: "/profile/:handle/slice/:rkey/records" }), 99 + pattern: new URLPattern({ 100 + pathname: "/profile/:handle/slice/:rkey/records", 101 + }), 89 102 handler: handleSliceRecordsPage, 90 103 }, 91 104 ];
+33 -22
frontend/src/features/slices/records/templates/SliceRecordsPage.tsx
··· 6 6 import { FileText } from "lucide-preact"; 7 7 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 8 8 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 9 - import type { IndexedRecord, NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 9 + import type { 10 + IndexedRecord, 11 + NetworkSlicesSliceDefsSliceView, 12 + } from "../../../../client.ts"; 10 13 11 14 interface Record extends IndexedRecord { 12 15 pretty_value?: string; ··· 26 29 author?: string; 27 30 search?: string; 28 31 currentUser?: AuthenticatedUser; 32 + hasSliceAccess?: boolean; 29 33 } 30 34 31 35 export function SliceRecordsPage({ ··· 37 41 author = "", 38 42 search = "", 39 43 currentUser, 44 + hasSliceAccess, 40 45 }: SliceRecordsPageProps) { 41 46 return ( 42 47 <SlicePage ··· 44 49 sliceId={sliceId} 45 50 currentTab="records" 46 51 currentUser={currentUser} 52 + hasSliceAccess={hasSliceAccess} 47 53 title={`${slice.name} - Records`} 48 54 > 49 55 <RecordFilterForm ··· 53 59 search={search} 54 60 /> 55 61 56 - {records.length > 0 ? ( 57 - <RecordsList records={records} /> 58 - ) : ( 59 - <div className="bg-white border border-zinc-200"> 60 - <EmptyState 61 - icon={<FileText size={64} strokeWidth={1} />} 62 - title="No records found" 63 - description={ 64 - collection || author || search 65 - ? "Try adjusting your filters or search terms, or sync some data first." 66 - : "Start by syncing some AT Protocol collections." 67 - } 68 - withPadding 69 - > 70 - <Button href={buildSliceUrlFromView(slice, sliceId, "sync")} variant="primary"> 71 - Go to Sync 72 - </Button> 73 - </EmptyState> 74 - </div> 75 - )} 62 + {records.length > 0 63 + ? <RecordsList records={records} /> 64 + : ( 65 + <div className="bg-white border border-zinc-200"> 66 + <EmptyState 67 + icon={<FileText size={64} strokeWidth={1} />} 68 + title="No records found" 69 + description={collection || author || search 70 + ? "Try adjusting your filters or search terms." 71 + : hasSliceAccess 72 + ? "Start by syncing some AT Protocol collections." 73 + : "This slice hasn't indexed any records yet."} 74 + withPadding 75 + > 76 + {hasSliceAccess && ( 77 + <Button 78 + href={buildSliceUrlFromView(slice, sliceId, "sync")} 79 + variant="primary" 80 + > 81 + Go to Sync 82 + </Button> 83 + )} 84 + </EmptyState> 85 + </div> 86 + )} 76 87 </SlicePage> 77 88 ); 78 - } 89 + }
+1 -1
frontend/src/features/slices/records/templates/fragments/RecordsList.tsx
··· 76 76 </div> 77 77 </div> 78 78 ); 79 - } 79 + }
+23 -10
frontend/src/features/slices/settings/handlers.tsx
··· 4 4 import { buildSliceUri } from "../../../utils/at-uri.ts"; 5 5 import { renderHTML } from "../../../utils/render.tsx"; 6 6 import { hxRedirect } from "../../../utils/htmx.ts"; 7 - import { withSliceAccess, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 7 + import { 8 + requireSliceAccess, 9 + withSliceAccess, 10 + } from "../../../routes/slice-middleware.ts"; 8 11 import { extractSliceParams } from "../../../utils/slice-params.ts"; 9 12 import { SliceSettings } from "./templates/SliceSettings.tsx"; 10 13 11 14 async function handleSliceSettingsPage( 12 15 req: Request, 13 - params?: URLPatternResult 16 + params?: URLPatternResult, 14 17 ): Promise<Response> { 15 18 const authContext = await withAuth(req); 16 19 const sliceParams = extractSliceParams(params); ··· 19 22 return Response.redirect(new URL("/", req.url), 302); 20 23 } 21 24 22 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 25 + const context = await withSliceAccess( 26 + authContext, 27 + sliceParams.handle, 28 + sliceParams.sliceId, 29 + ); 23 30 const accessError = requireSliceAccess(context); 24 31 if (accessError) return accessError; 25 32 ··· 34 41 updated={updated === "true"} 35 42 error={error} 36 43 currentUser={authContext.currentUser} 37 - /> 44 + />, 38 45 ); 39 46 } 40 47 41 48 async function handleUpdateSliceSettings( 42 49 req: Request, 43 - params?: URLPatternResult 50 + params?: URLPatternResult, 44 51 ): Promise<Response> { 45 52 const context = await withAuth(req); 46 53 const sliceId = params?.pathname.groups.id; ··· 81 88 domain: domain.trim(), 82 89 }); 83 90 84 - return hxRedirect(`/profile/${context.currentUser.handle}/slice/${sliceId}/settings?updated=true`); 91 + return hxRedirect( 92 + `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?updated=true`, 93 + ); 85 94 } catch (_error) { 86 - return hxRedirect(`/profile/${context.currentUser.handle}/slice/${sliceId}/settings?error=update_failed`); 95 + return hxRedirect( 96 + `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?error=update_failed`, 97 + ); 87 98 } 88 99 } 89 100 90 101 async function handleDeleteSlice( 91 102 req: Request, 92 - params?: URLPatternResult 103 + params?: URLPatternResult, 93 104 ): Promise<Response> { 94 105 const context = await withAuth(req); 95 106 const sliceId = params?.pathname.groups.id; ··· 106 117 // Delete the slice record from AT Protocol 107 118 await atprotoClient.network.slices.slice.deleteRecord(sliceId); 108 119 109 - return hxRedirect("/"); 120 + return hxRedirect(`/profile/${context.currentUser.handle}`); 110 121 } catch (_error) { 111 122 return new Response("Failed to delete slice", { status: 500 }); 112 123 } ··· 115 126 export const settingsRoutes: Route[] = [ 116 127 { 117 128 method: "GET", 118 - pattern: new URLPattern({ pathname: "/profile/:handle/slice/:rkey/settings" }), 129 + pattern: new URLPattern({ 130 + pathname: "/profile/:handle/slice/:rkey/settings", 131 + }), 119 132 handler: handleSliceSettingsPage, 120 133 }, 121 134 {
+41 -39
frontend/src/features/slices/settings/templates/SliceSettings.tsx
··· 37 37 {/* Error Message */} 38 38 {error && ( 39 39 <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 mb-4"> 40 - ❌ {error === "update_failed" ? "Failed to update slice settings. Please try again." : "An error occurred."} 40 + ❌ {error === "update_failed" 41 + ? "Failed to update slice settings. Please try again." 42 + : "An error occurred."} 41 43 </div> 42 44 )} 43 45 ··· 51 53 <p className="text-zinc-600 mb-4"> 52 54 Update your slice name and primary domain. 53 55 </p> 54 - <form 55 - hx-put={`/api/slices/${sliceId}/settings`} 56 - hx-target="#settings-form-result" 57 - hx-swap="innerHTML" 58 - className="space-y-4" 59 - > 56 + <form 57 + hx-put={`/api/slices/${sliceId}/settings`} 58 + hx-target="#settings-form-result" 59 + hx-swap="innerHTML" 60 + className="space-y-4" 61 + > 62 + <Input 63 + label="Slice Name" 64 + type="text" 65 + id="slice-name" 66 + name="name" 67 + value={slice.name} 68 + required 69 + placeholder="Enter slice name..." 70 + /> 71 + 72 + <div> 60 73 <Input 61 - label="Slice Name" 74 + label="Primary Domain" 62 75 type="text" 63 - id="slice-name" 64 - name="name" 65 - value={slice.name} 76 + id="slice-domain" 77 + name="domain" 78 + value={slice.domain || ""} 66 79 required 67 - placeholder="Enter slice name..." 80 + placeholder="e.g. social.grain" 68 81 /> 69 - 70 - <div> 71 - <Input 72 - label="Primary Domain" 73 - type="text" 74 - id="slice-domain" 75 - name="domain" 76 - value={slice.domain || ""} 77 - required 78 - placeholder="e.g. social.grain" 79 - /> 80 - <p className="mt-1 text-xs text-zinc-500"> 81 - Primary namespace for this slice's collections 82 - </p> 83 - </div> 82 + <p className="mt-1 text-xs text-zinc-500"> 83 + Primary namespace for this slice's collections 84 + </p> 85 + </div> 84 86 85 - <div className="flex justify-start"> 86 - <Button 87 - type="submit" 88 - variant="primary" 89 - size="lg" 90 - > 91 - Update Settings 92 - </Button> 93 - </div> 94 - <div id="settings-form-result" className="mt-4"></div> 95 - </form> 87 + <div className="flex justify-start"> 88 + <Button 89 + type="submit" 90 + variant="primary" 91 + size="lg" 92 + > 93 + Update Settings 94 + </Button> 95 + </div> 96 + <div id="settings-form-result" className="mt-4"></div> 97 + </form> 96 98 </div> 97 99 98 100 {/* Danger Zone */} ··· 119 121 </div> 120 122 </SlicePage> 121 123 ); 122 - } 124 + }
+3 -2
frontend/src/features/slices/shared/fragments/SliceLogPage.tsx
··· 3 3 import { PageHeader } from "../../../../shared/fragments/PageHeader.tsx"; 4 4 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 5 5 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 6 + import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 6 7 7 8 interface SliceLogPageProps { 8 9 slice: NetworkSlicesSliceDefsSliceView; ··· 25 26 breadcrumbLabel, 26 27 children, 27 28 }: SliceLogPageProps) { 28 - const defaultBreadcrumbHref = `/slices/${sliceId}`; 29 + const defaultBreadcrumbHref = buildSliceUrlFromView(slice, sliceId); 29 30 const defaultBreadcrumbLabel = `Back to ${slice.name}`; 30 31 31 32 return ( ··· 43 44 </div> 44 45 </Layout> 45 46 ); 46 - } 47 + }
+15 -3
frontend/src/features/slices/shared/fragments/SlicePage.tsx
··· 10 10 sliceId: string; 11 11 currentTab?: string; 12 12 currentUser?: AuthenticatedUser; 13 + hasSliceAccess?: boolean; 13 14 title?: string; 14 15 headerActions?: preact.ComponentChildren; 15 16 breadcrumbHref?: string; ··· 22 23 sliceId, 23 24 currentTab, 24 25 currentUser, 26 + hasSliceAccess, 25 27 title, 26 28 headerActions, 27 29 breadcrumbHref, ··· 29 31 children, 30 32 }: SlicePageProps) { 31 33 const pageTitle = title || slice.name; 32 - const defaultBreadcrumbHref = slice.creator?.handle ? `/profile/${slice.creator.handle}` : "/"; 34 + const defaultBreadcrumbHref = slice.creator?.handle 35 + ? `/profile/${slice.creator.handle}` 36 + : "/"; 33 37 const defaultBreadcrumbLabel = "Back to Profile"; 34 38 35 39 return ( ··· 43 47 {headerActions} 44 48 </PageHeader> 45 49 46 - {currentTab && <SliceTabs slice={slice} sliceId={sliceId} currentTab={currentTab} />} 50 + {currentTab && ( 51 + <SliceTabs 52 + slice={slice} 53 + sliceId={sliceId} 54 + currentTab={currentTab} 55 + currentUser={currentUser} 56 + hasSliceAccess={hasSliceAccess} 57 + /> 58 + )} 47 59 48 60 {children} 49 61 </div> 50 62 </Layout> 51 63 ); 52 - } 64 + }
+64 -11
frontend/src/features/slices/shared/fragments/SliceTabs.tsx
··· 1 1 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 2 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 2 3 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 3 4 4 5 export interface SliceTab { ··· 7 8 href: string; 8 9 } 9 10 10 - export function getSliceTabs(slice: NetworkSlicesSliceDefsSliceView, sliceId: string): SliceTab[] { 11 - return [ 12 - { id: "overview", name: "Overview", href: buildSliceUrlFromView(slice, sliceId) }, 13 - { id: "lexicon", name: "Lexicons", href: buildSliceUrlFromView(slice, sliceId, "lexicon") }, 14 - { id: "sync", name: "Sync", href: buildSliceUrlFromView(slice, sliceId, "sync") }, 15 - { id: "records", name: "Records", href: buildSliceUrlFromView(slice, sliceId, "records") }, 16 - { id: "codegen", name: "Code Gen", href: buildSliceUrlFromView(slice, sliceId, "codegen") }, 17 - { id: "oauth", name: "OAuth Clients", href: buildSliceUrlFromView(slice, sliceId, "oauth") }, 18 - { id: "settings", name: "Settings", href: buildSliceUrlFromView(slice, sliceId, "settings") }, 11 + export function getSliceTabs( 12 + slice: NetworkSlicesSliceDefsSliceView, 13 + sliceId: string, 14 + currentUser?: AuthenticatedUser, 15 + hasSliceAccess?: boolean, 16 + ): SliceTab[] { 17 + const tabs = [ 18 + { 19 + id: "overview", 20 + name: "Overview", 21 + href: buildSliceUrlFromView(slice, sliceId), 22 + }, 23 + { 24 + id: "lexicon", 25 + name: "Lexicons", 26 + href: buildSliceUrlFromView(slice, sliceId, "lexicon"), 27 + }, 19 28 ]; 29 + 30 + // Add sync tab only if user owns the slice 31 + if (hasSliceAccess) { 32 + tabs.push({ 33 + id: "sync", 34 + name: "Sync", 35 + href: buildSliceUrlFromView(slice, sliceId, "sync"), 36 + }); 37 + } 38 + 39 + tabs.push( 40 + { 41 + id: "records", 42 + name: "Records", 43 + href: buildSliceUrlFromView(slice, sliceId, "records"), 44 + }, 45 + { 46 + id: "codegen", 47 + name: "Code Gen", 48 + href: buildSliceUrlFromView(slice, sliceId, "codegen"), 49 + }, 50 + ); 51 + 52 + // Add oauth and settings tabs only if user owns the slice 53 + if (hasSliceAccess) { 54 + tabs.push( 55 + { 56 + id: "oauth", 57 + name: "OAuth Clients", 58 + href: buildSliceUrlFromView(slice, sliceId, "oauth"), 59 + }, 60 + { 61 + id: "settings", 62 + name: "Settings", 63 + href: buildSliceUrlFromView(slice, sliceId, "settings"), 64 + }, 65 + ); 66 + } 67 + 68 + return tabs; 20 69 } 21 70 22 71 interface SliceTabsProps { 23 72 slice: NetworkSlicesSliceDefsSliceView; 24 73 sliceId: string; 25 74 currentTab: string; 75 + currentUser?: AuthenticatedUser; 76 + hasSliceAccess?: boolean; 26 77 } 27 78 28 - export function SliceTabs({ slice, sliceId, currentTab }: SliceTabsProps) { 29 - const tabs = getSliceTabs(slice, sliceId); 79 + export function SliceTabs( 80 + { slice, sliceId, currentTab, currentUser, hasSliceAccess }: SliceTabsProps, 81 + ) { 82 + const tabs = getSliceTabs(slice, sliceId, currentUser, hasSliceAccess); 30 83 31 84 return ( 32 85 <nav className="border-b border-gray-200 mb-6">
+19 -10
frontend/src/features/slices/sync-logs/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { renderHTML } from "../../../utils/render.tsx"; 3 - import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 3 + import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 4 4 import { getSliceClient } from "../../../utils/client.ts"; 5 - import { withSliceAccess, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 5 + import { 6 + requireSliceAccess, 7 + withSliceAccess, 8 + } from "../../../routes/slice-middleware.ts"; 6 9 import { extractSliceParams } from "../../../utils/slice-params.ts"; 7 10 import { SyncJobLogsPage } from "./templates/SyncJobLogsPage.tsx"; 8 11 import { SyncJobLogs } from "./templates/SyncJobLogs.tsx"; 9 12 10 13 async function handleSyncJobLogsPage( 11 14 req: Request, 12 - params?: URLPatternResult 15 + params?: URLPatternResult, 13 16 ): Promise<Response> { 14 17 const authContext = await withAuth(req); 15 18 const sliceParams = extractSliceParams(params); ··· 19 22 return new Response("Invalid slice ID or job ID", { status: 400 }); 20 23 } 21 24 22 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 25 + const context = await withSliceAccess( 26 + authContext, 27 + sliceParams.handle, 28 + sliceParams.sliceId, 29 + ); 23 30 const accessError = requireSliceAccess(context); 24 31 if (accessError) return accessError; 25 32 ··· 29 36 sliceId={sliceParams.sliceId} 30 37 jobId={jobId} 31 38 currentUser={authContext.currentUser} 32 - /> 39 + />, 33 40 ); 34 41 } 35 42 36 43 async function handleSyncJobLogs( 37 44 req: Request, 38 - params?: URLPatternResult 45 + params?: URLPatternResult, 39 46 ): Promise<Response> { 40 47 const context = await withAuth(req); 41 48 const authResponse = requireAuth(context); ··· 49 56 <div className="p-8 text-center text-red-600"> 50 57 Invalid slice ID or job ID 51 58 </div>, 52 - { status: 400 } 59 + { status: 400 }, 53 60 ); 54 61 } 55 62 ··· 64 71 } 65 72 66 73 return renderHTML( 67 - <div className="p-8 text-center text-gray-600">No logs available</div> 74 + <div className="p-8 text-center text-gray-600">No logs available</div>, 68 75 ); 69 76 } catch (error) { 70 77 console.error("Failed to get sync job logs:", error); ··· 72 79 return renderHTML( 73 80 <div className="p-8 text-center text-red-600"> 74 81 Failed to load logs: {errorMessage} 75 - </div> 82 + </div>, 76 83 ); 77 84 } 78 85 } ··· 80 87 export const syncLogsRoutes: Route[] = [ 81 88 { 82 89 method: "GET", 83 - pattern: new URLPattern({ pathname: "/profile/:handle/slice/:rkey/sync/:jobId" }), 90 + pattern: new URLPattern({ 91 + pathname: "/profile/:handle/slice/:rkey/sync/:jobId", 92 + }), 84 93 handler: handleSyncJobLogsPage, 85 94 }, 86 95 {
+3 -3
frontend/src/features/slices/sync-logs/templates/SyncJobLogs.tsx
··· 7 7 8 8 export function SyncJobLogs({ logs }: SyncJobLogsProps) { 9 9 return ( 10 - <LogViewer 11 - logs={logs} 10 + <LogViewer 11 + logs={logs} 12 12 emptyMessage="No logs available for this sync job." 13 13 /> 14 14 ); 15 - } 15 + }
+2 -2
frontend/src/features/slices/sync-logs/templates/SyncJobLogsPage.tsx
··· 30 30 </div> 31 31 } 32 32 > 33 - <div 33 + <div 34 34 className="bg-white border border-zinc-200" 35 35 hx-get={`/api/slices/${sliceId}/sync/${jobId}`} 36 36 hx-trigger="load" ··· 42 42 </div> 43 43 </SliceLogPage> 44 44 ); 45 - } 45 + }
+117 -29
frontend/src/features/slices/sync/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { renderHTML } from "../../../utils/render.tsx"; 3 - import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 3 + import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 4 4 import { getSliceClient } from "../../../utils/client.ts"; 5 5 import { buildSliceUri } from "../../../utils/at-uri.ts"; 6 - import { withSliceAccess, requireSliceAccess } from "../../../routes/slice-middleware.ts"; 7 - import { extractSliceParams } from "../../../utils/slice-params.ts"; 6 + import { atprotoClient } from "../../../config.ts"; 7 + import { 8 + requireSliceAccess, 9 + withSliceAccess, 10 + } from "../../../routes/slice-middleware.ts"; 11 + import { 12 + buildSliceUrl, 13 + extractSliceParams, 14 + } from "../../../utils/slice-params.ts"; 8 15 import { SliceSyncPage } from "./templates/SliceSyncPage.tsx"; 16 + import { hxRedirect } from "../../../utils/htmx.ts"; 17 + import { SyncFormModal } from "./templates/fragments/SyncFormModal.tsx"; 9 18 import { SyncResult } from "./templates/fragments/SyncResult.tsx"; 10 19 import { JobHistory } from "./templates/fragments/JobHistory.tsx"; 11 20 12 21 async function handleSliceSync( 13 22 req: Request, 14 - params?: URLPatternResult 23 + params?: URLPatternResult, 15 24 ): Promise<Response> { 16 25 const context = await withAuth(req); 17 26 const authResponse = requireAuth(context); ··· 50 59 <SyncResult 51 60 success={false} 52 61 error="Please specify at least one collection (primary or external) to sync" 53 - /> 62 + />, 54 63 ); 55 64 } 56 65 57 66 const sliceClient = getSliceClient(context, sliceId); 58 67 const syncJobResponse = await sliceClient.network.slices.slice.startSync({ 59 68 collections: collections.length > 0 ? collections : undefined, 60 - externalCollections: 61 - externalCollections.length > 0 ? externalCollections : undefined, 69 + externalCollections: externalCollections.length > 0 70 + ? externalCollections 71 + : undefined, 62 72 repos: repos.length > 0 ? repos : undefined, 63 73 }); 64 74 65 - return renderHTML( 66 - <SyncResult 67 - success={syncJobResponse.success} 68 - message={ 69 - syncJobResponse.success 70 - ? `Sync job started successfully. Job ID: ${syncJobResponse.jobId}` 71 - : syncJobResponse.message 72 - } 73 - jobId={syncJobResponse.jobId} 74 - collectionsCount={collections.length + externalCollections.length} 75 - error={syncJobResponse.success ? undefined : syncJobResponse.message} 76 - /> 77 - ); 75 + if (syncJobResponse.success) { 76 + // Get the user's handle for the redirect 77 + const handle = context.currentUser?.handle; 78 + if (!handle) { 79 + throw new Error("Unable to determine user handle"); 80 + } 81 + 82 + // Redirect to the sync page to show the job started 83 + const redirectUrl = buildSliceUrl(handle, sliceId, "sync"); 84 + return hxRedirect(redirectUrl); 85 + } else { 86 + return renderHTML( 87 + <SyncResult 88 + success={false} 89 + message={syncJobResponse.message} 90 + error={syncJobResponse.message} 91 + />, 92 + ); 93 + } 78 94 } catch (error) { 79 95 console.error("Failed to start sync:", error); 80 96 const errorMessage = error instanceof Error ? error.message : String(error); ··· 84 100 85 101 async function handleJobHistory( 86 102 req: Request, 87 - params?: URLPatternResult 103 + params?: URLPatternResult, 88 104 ): Promise<Response> { 89 105 const context = await withAuth(req); 90 106 const authResponse = requireAuth(context); ··· 95 111 if (!sliceId) { 96 112 return renderHTML( 97 113 <div className="p-8 text-center text-red-600">Invalid slice ID</div>, 98 - { status: 400 } 114 + { status: 400 }, 99 115 ); 100 116 } 101 117 ··· 117 133 jobs={jobsResponse || []} 118 134 sliceId={sliceId} 119 135 handle={handle || undefined} 120 - /> 136 + />, 121 137 ); 122 138 } catch (error) { 123 139 console.error("Failed to fetch job history:", error); 124 140 return renderHTML( 125 141 <div className="p-8 text-center text-red-600"> 126 142 Failed to load job history 127 - </div> 143 + </div>, 128 144 ); 129 145 } 130 146 } 131 147 132 148 async function handleSliceSyncPage( 133 149 req: Request, 134 - params?: URLPatternResult 150 + params?: URLPatternResult, 135 151 ): Promise<Response> { 136 152 const authContext = await withAuth(req); 137 153 const sliceParams = extractSliceParams(params); ··· 140 156 return new Response("Invalid slice ID", { status: 400 }); 141 157 } 142 158 143 - const context = await withSliceAccess(authContext, sliceParams.handle, sliceParams.sliceId); 159 + const context = await withSliceAccess( 160 + authContext, 161 + sliceParams.handle, 162 + sliceParams.sliceId, 163 + ); 144 164 const accessError = requireSliceAccess(context); 145 165 if (accessError) return accessError; 146 166 ··· 150 170 151 171 // Get all lexicons and filter by record types 152 172 try { 153 - const lexiconsResponse = 154 - await sliceClient.network.slices.lexicon.getRecords(); 173 + const lexiconsResponse = await sliceClient.network.slices.lexicon 174 + .getRecords(); 155 175 const recordLexicons = lexiconsResponse.records.filter((lexicon) => { 156 176 try { 157 177 const definitions = JSON.parse(lexicon.value.definitions); ··· 182 202 currentUser={authContext.currentUser} 183 203 collections={collections} 184 204 externalCollections={externalCollections} 185 - /> 205 + />, 186 206 ); 187 207 } 188 208 209 + async function handleShowSyncModal( 210 + req: Request, 211 + params?: URLPatternResult, 212 + ): Promise<Response> { 213 + const context = await withAuth(req); 214 + const authResponse = requireAuth(context); 215 + if (authResponse) return authResponse; 216 + 217 + const sliceId = params?.pathname.groups.id; 218 + if (!sliceId) { 219 + return new Response("Invalid slice ID", { status: 400 }); 220 + } 221 + 222 + try { 223 + const sliceClient = getSliceClient(context, sliceId); 224 + const collections: string[] = []; 225 + const externalCollections: string[] = []; 226 + 227 + // Get slice info for domain comparison 228 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 229 + const sliceRecord = await atprotoClient.network.slices.slice.getRecord({ 230 + uri: sliceUri, 231 + }); 232 + const sliceDomain = sliceRecord.value.domain; 233 + 234 + // Get all lexicons and filter by record types 235 + try { 236 + const lexiconsResponse = await sliceClient.network.slices.lexicon 237 + .getRecords(); 238 + const recordLexicons = lexiconsResponse.records.filter((lexicon) => { 239 + try { 240 + const definitions = JSON.parse(lexicon.value.definitions); 241 + return definitions.main.type === "record"; 242 + } catch { 243 + return false; 244 + } 245 + }); 246 + 247 + // Categorize by domain - primary collections match slice domain, external don't 248 + recordLexicons.forEach((lexicon) => { 249 + if (lexicon.value.nsid.startsWith(sliceDomain)) { 250 + collections.push(lexicon.value.nsid); 251 + } else { 252 + externalCollections.push(lexicon.value.nsid); 253 + } 254 + }); 255 + } catch (error) { 256 + console.error("Error fetching lexicons:", error); 257 + } 258 + 259 + return renderHTML( 260 + <SyncFormModal 261 + sliceId={sliceId} 262 + collections={collections} 263 + externalCollections={externalCollections} 264 + />, 265 + ); 266 + } catch (error) { 267 + console.error("Error loading sync modal:", error); 268 + return renderHTML(<SyncFormModal sliceId={sliceId} />); 269 + } 270 + } 271 + 189 272 export const syncRoutes: Route[] = [ 190 273 { 191 274 method: "GET", 192 275 pattern: new URLPattern({ pathname: "/profile/:handle/slice/:rkey/sync" }), 193 276 handler: handleSliceSyncPage, 277 + }, 278 + { 279 + method: "GET", 280 + pattern: new URLPattern({ pathname: "/api/slices/:id/sync/modal" }), 281 + handler: handleShowSyncModal, 194 282 }, 195 283 { 196 284 method: "POST",
+18 -76
frontend/src/features/slices/sync/templates/SliceSyncPage.tsx
··· 1 1 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 2 2 import { Button } from "../../../../shared/fragments/Button.tsx"; 3 - import { Textarea } from "../../../../shared/fragments/Textarea.tsx"; 4 3 import { JobHistory } from "./fragments/JobHistory.tsx"; 4 + import { RefreshCw } from "lucide-preact"; 5 5 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 6 6 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 7 7 ··· 28 28 currentUser={currentUser} 29 29 title={`${slice.name} - Sync`} 30 30 > 31 - <div className="bg-white border border-zinc-200 p-6 mb-6"> 32 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 33 - Sync Collections 31 + <div className="bg-white border border-zinc-200"> 32 + <div className="px-6 py-4 border-b border-zinc-200 flex items-center justify-between"> 33 + <h2 className="text-lg font-semibold text-zinc-900"> 34 + Recent Sync History 34 35 </h2> 35 - <p className="text-zinc-600 mb-6"> 36 - Sync entire collections from AT Protocol network to this slice. 37 - </p> 38 - 39 - <form 40 - hx-post={`/api/slices/${sliceId}/sync`} 41 - hx-target="#sync-result" 36 + <Button 37 + variant="success" 38 + hx-get={`/api/slices/${sliceId}/sync/modal`} 39 + hx-target="#modal-container" 42 40 hx-swap="innerHTML" 43 - hx-on="htmx:afterRequest: if(event.detail.successful) this.reset()" 44 - className="space-y-4" 45 41 > 46 - <Textarea 47 - id="collections" 48 - name="collections" 49 - label="Primary Collections" 50 - rows={4} 51 - placeholder={ 52 - collections.length > 0 53 - ? "Primary collections (matching your slice domain) loaded below:" 54 - : "Enter primary collections matching your slice domain, one per line:\n\nyour.domain.collection\nyour.domain.post" 55 - } 56 - defaultValue={collections.length > 0 ? collections.join("\n") : ""} 57 - /> 58 - <p className="mt-1 text-xs text-zinc-500"> 59 - Primary collections are those that match your slice's domain. 60 - </p> 61 - 62 - <Textarea 63 - id="external_collections" 64 - name="external_collections" 65 - label="External Collections" 66 - rows={4} 67 - placeholder={ 68 - externalCollections.length > 0 69 - ? "External collections loaded below:" 70 - : "Enter external collections (not matching your domain), one per line:\n\napp.bsky.feed.post\napp.bsky.actor.profile" 71 - } 72 - defaultValue={externalCollections.length > 0 ? externalCollections.join("\n") : ""} 73 - /> 74 - <p className="mt-1 text-xs text-zinc-500"> 75 - External collections are those that don't match your slice's domain. 76 - </p> 77 - 78 - <Textarea 79 - id="repos" 80 - name="repos" 81 - label="Specific Repositories (Optional)" 82 - rows={4} 83 - placeholder="Leave empty to sync all repositories, or specify DIDs: 84 - 85 - did:plc:example1 86 - did:plc:example2" 87 - /> 88 - 89 - <div className="flex space-x-4"> 90 - <Button 91 - type="submit" 92 - variant="success" 93 - className="flex items-center justify-center" 94 - > 95 - <i 96 - data-lucide="loader-2" 97 - className="htmx-indicator animate-spin mr-2 h-4 w-4" 98 - _="on load js lucide.createIcons() end" 99 - ></i> 100 - <span className="htmx-indicator">Syncing...</span> 101 - <span className="default-text">Start Sync</span> 102 - </Button> 103 - </div> 104 - </form> 105 - 106 - <div id="sync-result" className="mt-4"></div> 42 + <span className="flex items-center gap-2"> 43 + <RefreshCw size={16} /> 44 + Start Sync 45 + </span> 46 + </Button> 107 47 </div> 108 - 109 48 <div 110 49 hx-get={`/api/slices/${sliceId}/job-history?handle=${slice.creator?.handle}`} 111 50 hx-trigger="load, every 10s" ··· 113 52 > 114 53 <JobHistory jobs={[]} sliceId={sliceId} /> 115 54 </div> 55 + </div> 56 + 57 + <div id="modal-container"></div> 116 58 </SlicePage> 117 59 ); 118 - } 60 + }
+62 -52
frontend/src/features/slices/sync/templates/fragments/JobHistory.tsx
··· 62 62 export function JobHistory({ jobs, sliceId, handle }: JobHistoryProps) { 63 63 if (jobs.length === 0) { 64 64 return ( 65 - <div className="bg-white border border-zinc-200"> 66 - <div className="px-6 py-4 border-b border-zinc-200"> 67 - <h3 className="text-lg font-semibold text-zinc-900">Recent Sync History</h3> 68 - </div> 69 - <EmptyState 70 - icon={<Clock size={64} strokeWidth={1} />} 71 - title="No sync history yet" 72 - description="Sync jobs will appear here once completed." 73 - withPadding 74 - /> 75 - </div> 65 + <EmptyState 66 + icon={<Clock size={64} strokeWidth={1} />} 67 + title="No sync history yet" 68 + description="Sync jobs will appear here once completed." 69 + withPadding 70 + /> 76 71 ); 77 72 } 78 73 79 74 return ( 80 - <div className="bg-white border border-zinc-200"> 81 - <div className="px-6 py-4 border-b border-zinc-200"> 82 - <h3 className="text-lg font-semibold text-zinc-900">Recent Sync History</h3> 83 - </div> 84 - <div className="divide-y divide-zinc-200"> 85 - {jobs.map((job) => ( 86 - <div key={job.jobId} className="group"> 87 - <a 88 - href={handle ? buildSliceUrl(handle, sliceId, `sync/${job.jobId}`) : `/slices/${sliceId}/sync/${job.jobId}`} 89 - className="block px-6 py-4 hover:bg-zinc-50 transition-colors" 90 - > 91 - <div className="flex justify-between items-center"> 92 - <div> 93 - <div className="flex items-center gap-2 mb-1"> 94 - {job.result?.success ? ( 75 + <div className="divide-y divide-zinc-200"> 76 + {jobs.map((job) => ( 77 + <div key={job.jobId} className="group"> 78 + <a 79 + href={handle 80 + ? buildSliceUrl(handle, sliceId, `sync/${job.jobId}`) 81 + : `/slices/${sliceId}/sync/${job.jobId}`} 82 + className="block px-6 py-4 hover:bg-zinc-50 transition-colors" 83 + > 84 + <div className="flex justify-between items-center"> 85 + <div> 86 + <div className="flex items-center gap-2 mb-1"> 87 + {!job.result 88 + ? ( 89 + <span className="text-blue-600 font-medium"> 90 + 🔄 Running 91 + </span> 92 + ) 93 + : job.result.success 94 + ? ( 95 95 <span className="text-green-600 font-medium"> 96 96 ✅ Success 97 97 </span> 98 - ) : ( 99 - <span className="text-red-600 font-medium">❌ Failed</span> 100 - )} 101 - {job.result?.message && ( 102 - <span className="text-zinc-400 text-xs"> 103 - ({extractDurationFromMessage(job.result.message)}) 98 + ) 99 + : ( 100 + <span className="text-red-600 font-medium"> 101 + ❌ Failed 104 102 </span> 105 103 )} 106 - </div> 107 - <p className="text-sm text-zinc-500"> 108 - {formatDate(job.createdAt)} 104 + {job.result?.message && ( 105 + <span className="text-zinc-400 text-xs"> 106 + ({extractDurationFromMessage(job.result.message)}) 107 + </span> 108 + )} 109 + </div> 110 + <p className="text-sm text-zinc-500"> 111 + {job.completedAt 112 + ? `Completed ${formatDate(job.completedAt)}` 113 + : `Started ${formatDate(job.createdAt)}`} 114 + </p> 115 + {job.result && ( 116 + <p className="text-xs text-zinc-400 mt-1"> 117 + {job.result.totalRecords} records •{" "} 118 + {job.result.reposProcessed} repos 109 119 </p> 110 - {job.result && ( 111 - <p className="text-xs text-zinc-400 mt-1"> 112 - {job.result.totalRecords} records • {job.result.reposProcessed} repos 113 - </p> 114 - )} 120 + )} 121 + {!job.result && ( 122 + <p className="text-xs text-zinc-400 mt-1"> 123 + Job in progress... 124 + </p> 125 + )} 126 + </div> 127 + <div className="flex items-center space-x-2"> 128 + <div className="text-xs text-zinc-400 font-mono"> 129 + {job.jobId.split("-")[0]}... 115 130 </div> 116 - <div className="flex items-center space-x-2"> 117 - <div className="text-xs text-zinc-400 font-mono"> 118 - {job.jobId.split("-")[0]}... 119 - </div> 120 - <div className="text-zinc-400"> 121 - <ChevronRight size={20} /> 122 - </div> 131 + <div className="text-zinc-400"> 132 + <ChevronRight size={20} /> 123 133 </div> 124 134 </div> 125 - </a> 126 - </div> 127 - ))} 128 - </div> 135 + </div> 136 + </a> 137 + </div> 138 + ))} 129 139 </div> 130 140 ); 131 - } 141 + }
+96
frontend/src/features/slices/sync/templates/fragments/SyncFormModal.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + import { Textarea } from "../../../../../shared/fragments/Textarea.tsx"; 3 + import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 4 + 5 + interface SyncFormModalProps { 6 + sliceId: string; 7 + collections?: string[]; 8 + externalCollections?: string[]; 9 + } 10 + 11 + export function SyncFormModal({ 12 + sliceId, 13 + collections = [], 14 + externalCollections = [], 15 + }: SyncFormModalProps) { 16 + return ( 17 + <Modal 18 + title="Sync Collections" 19 + description="Sync entire collections from AT Protocol network to this slice." 20 + > 21 + <form 22 + hx-post={`/api/slices/${sliceId}/sync`} 23 + hx-target="#sync-result" 24 + hx-swap="innerHTML" 25 + className="space-y-4" 26 + > 27 + <Textarea 28 + id="collections" 29 + name="collections" 30 + label="Primary Collections" 31 + rows={4} 32 + placeholder={collections.length > 0 33 + ? "Primary collections (matching your slice domain) loaded below:" 34 + : "Enter primary collections matching your slice domain, one per line:\n\nyour.domain.collection\nyour.domain.post"} 35 + defaultValue={collections.length > 0 ? collections.join("\n") : ""} 36 + /> 37 + <p className="mt-1 text-xs text-zinc-500"> 38 + Primary collections are those that match your slice's domain. 39 + </p> 40 + 41 + <Textarea 42 + id="external_collections" 43 + name="external_collections" 44 + label="External Collections" 45 + rows={4} 46 + placeholder={externalCollections.length > 0 47 + ? "External collections loaded below:" 48 + : "Enter external collections (not matching your domain), one per line:\n\napp.bsky.feed.post\napp.bsky.actor.profile"} 49 + defaultValue={externalCollections.length > 0 50 + ? externalCollections.join("\n") 51 + : ""} 52 + /> 53 + <p className="mt-1 text-xs text-zinc-500"> 54 + External collections are those that don't match your slice's domain. 55 + </p> 56 + 57 + <Textarea 58 + id="repos" 59 + name="repos" 60 + label="Specific Repositories (Optional)" 61 + rows={4} 62 + placeholder="Leave empty to sync all repositories, or specify DIDs: 63 + 64 + did:plc:example1 65 + did:plc:example2" 66 + /> 67 + 68 + <div id="sync-result" className="mt-4"></div> 69 + 70 + <div className="flex justify-end gap-3"> 71 + <Button 72 + type="button" 73 + variant="secondary" 74 + _="on click set #modal-container's innerHTML to ''" 75 + > 76 + Cancel 77 + </Button> 78 + <Button 79 + type="submit" 80 + variant="success" 81 + className="flex items-center justify-center" 82 + > 83 + <i 84 + data-lucide="loader-2" 85 + className="htmx-indicator animate-spin mr-2 h-4 w-4" 86 + _="on load js lucide.createIcons() end" 87 + > 88 + </i> 89 + <span className="htmx-indicator">Syncing...</span> 90 + <span className="default-text">Start Sync</span> 91 + </Button> 92 + </div> 93 + </form> 94 + </Modal> 95 + ); 96 + }
+4 -2
frontend/src/features/slices/sync/templates/fragments/SyncResult.tsx
··· 29 29 </div> 30 30 )} 31 31 </div> 32 - {message && <div className="text-green-600 text-xs mt-2">{message}</div>} 32 + {message && ( 33 + <div className="text-green-600 text-xs mt-2">{message}</div> 34 + )} 33 35 </div> 34 36 ); 35 37 } ··· 42 44 </div> 43 45 </div> 44 46 ); 45 - } 47 + }
+37 -13
frontend/src/lib/api.ts
··· 1 1 import { 2 2 AtProtoClient, 3 + NetworkSlicesActorDefsProfileViewBasic, 4 + NetworkSlicesActorProfile, 3 5 NetworkSlicesSlice, 4 6 NetworkSlicesSliceDefsSliceView, 5 - NetworkSlicesActorProfile, 6 - NetworkSlicesActorDefsProfileViewBasic, 7 7 } from "../client.ts"; 8 - import { RecordResponse, recordBlobToCdnUrl } from "@slices/client"; 8 + import { recordBlobToCdnUrl, RecordResponse } from "@slices/client"; 9 9 10 10 export async function getSlice( 11 11 client: AtProtoClient, 12 - uri: string 12 + uri: string, 13 13 ): Promise<NetworkSlicesSliceDefsSliceView | null> { 14 14 try { 15 15 const sliceRecord = await client.network.slices.slice.getRecord({ uri }); ··· 27 27 28 28 export async function getSliceActor( 29 29 client: AtProtoClient, 30 - did: string 30 + did: string, 31 31 ): Promise<NetworkSlicesActorDefsProfileViewBasic | null> { 32 32 try { 33 33 const profileUri = `at://${did}/network.slices.actor.profile/self`; ··· 42 42 limit: 1, 43 43 }); 44 44 45 - const handle = 46 - actorsResponse.actors.length > 0 47 - ? actorsResponse.actors[0].handle ?? did 48 - : did; // fallback to DID if no handle found 45 + const handle = actorsResponse.actors.length > 0 46 + ? actorsResponse.actors[0].handle ?? did 47 + : did; // fallback to DID if no handle found 49 48 50 49 return actorToView(profileRecord, handle); 51 50 } catch (error) { ··· 56 55 57 56 export function sliceToView( 58 57 sliceRecord: RecordResponse<NetworkSlicesSlice>, 59 - creator: NetworkSlicesActorDefsProfileViewBasic 58 + creator: NetworkSlicesActorDefsProfileViewBasic, 60 59 ): NetworkSlicesSliceDefsSliceView { 61 60 return { 62 61 uri: sliceRecord.uri, ··· 70 69 71 70 export function actorToView( 72 71 profileRecord: RecordResponse<NetworkSlicesActorProfile>, 73 - handle: string 72 + handle: string, 74 73 ): NetworkSlicesActorDefsProfileViewBasic { 75 74 return { 76 75 did: profileRecord.did, ··· 85 84 86 85 export async function getSlicesForActor( 87 86 client: AtProtoClient, 88 - did: string 87 + did: string, 89 88 ): Promise<NetworkSlicesSliceDefsSliceView[]> { 90 89 try { 91 90 const slicesResponse = await client.network.slices.slice.getRecords({ ··· 112 111 export async function searchSlices( 113 112 client: AtProtoClient, 114 113 query: string, 115 - limit = 20 114 + limit = 20, 116 115 ): Promise<NetworkSlicesSliceDefsSliceView[]> { 117 116 try { 118 117 const slicesResponse = await client.network.slices.slice.getRecords({ ··· 136 135 return []; 137 136 } 138 137 } 138 + 139 + export async function getTimeline( 140 + client: AtProtoClient, 141 + limit = 50, 142 + ): Promise<NetworkSlicesSliceDefsSliceView[]> { 143 + try { 144 + const slicesResponse = await client.network.slices.slice.getRecords({ 145 + limit, 146 + sortBy: [{ field: "createdAt", direction: "desc" }], 147 + }); 148 + 149 + const sliceViews: NetworkSlicesSliceDefsSliceView[] = []; 150 + for (const sliceRecord of slicesResponse.records) { 151 + const creator = await getSliceActor(client, sliceRecord.did); 152 + if (creator) { 153 + sliceViews.push(sliceToView(sliceRecord, creator)); 154 + } 155 + } 156 + 157 + return sliceViews; 158 + } catch (error) { 159 + console.error("Failed to get timeline:", error); 160 + return []; 161 + } 162 + }
+52 -34
frontend/src/lib/request_logger.ts
··· 2 2 3 3 // ANSI color codes 4 4 const colors = { 5 - reset: '\x1b[0m', 6 - bright: '\x1b[1m', 7 - dim: '\x1b[2m', 8 - red: '\x1b[31m', 9 - green: '\x1b[32m', 10 - yellow: '\x1b[33m', 11 - blue: '\x1b[34m', 12 - magenta: '\x1b[35m', 13 - cyan: '\x1b[36m', 14 - gray: '\x1b[90m', 5 + reset: "\x1b[0m", 6 + bright: "\x1b[1m", 7 + dim: "\x1b[2m", 8 + red: "\x1b[31m", 9 + green: "\x1b[32m", 10 + yellow: "\x1b[33m", 11 + blue: "\x1b[34m", 12 + magenta: "\x1b[35m", 13 + cyan: "\x1b[36m", 14 + gray: "\x1b[90m", 15 15 }; 16 16 17 17 function getStatusColor(status: number): string { ··· 24 24 25 25 function getMethodColor(method: string): string { 26 26 switch (method) { 27 - case 'GET': return colors.blue; 28 - case 'POST': return colors.green; 29 - case 'PUT': return colors.yellow; 30 - case 'DELETE': return colors.red; 31 - case 'PATCH': return colors.magenta; 32 - default: return colors.gray; 27 + case "GET": 28 + return colors.blue; 29 + case "POST": 30 + return colors.green; 31 + case "PUT": 32 + return colors.yellow; 33 + case "DELETE": 34 + return colors.red; 35 + case "PATCH": 36 + return colors.magenta; 37 + default: 38 + return colors.gray; 33 39 } 34 40 } 35 41 36 - export function logRequest(req: Request, start: number, response: Response): void { 42 + export function logRequest( 43 + req: Request, 44 + start: number, 45 + response: Response, 46 + ): void { 37 47 const duration = Date.now() - start; 38 48 const url = new URL(req.url); 39 49 const method = req.method; 40 50 const status = response.status; 41 51 const userAgent = req.headers.get("user-agent") || "-"; 42 52 const referer = req.headers.get("referer") || "-"; 43 - 53 + 44 54 const methodColored = `${getMethodColor(method)}${method}${colors.reset}`; 45 55 const statusColored = `${getStatusColor(status)}${status}${colors.reset}`; 46 - const pathColored = `${colors.bright}${url.pathname}${url.search}${colors.reset}`; 47 - const durationColored = duration > 1000 ? 48 - `${colors.red}${duration}ms${colors.reset}` : 49 - duration > 500 ? 50 - `${colors.yellow}${duration}ms${colors.reset}` : 51 - `${colors.dim}${duration}ms${colors.reset}`; 52 - 56 + const pathColored = 57 + `${colors.bright}${url.pathname}${url.search}${colors.reset}`; 58 + const durationColored = duration > 1000 59 + ? `${colors.red}${duration}ms${colors.reset}` 60 + : duration > 500 61 + ? `${colors.yellow}${duration}ms${colors.reset}` 62 + : `${colors.dim}${duration}ms${colors.reset}`; 63 + 53 64 console.log( 54 - `${methodColored} ${pathColored} ${statusColored} ${durationColored} ${colors.gray}- ${userAgent} - ${referer}${colors.reset}` 65 + `${methodColored} ${pathColored} ${statusColored} ${durationColored} ${colors.gray}- ${userAgent} - ${referer}${colors.reset}`, 55 66 ); 56 67 } 57 68 58 - export function createLoggingHandler(handler: (req: Request) => Response | Promise<Response>) { 69 + export function createLoggingHandler( 70 + handler: (req: Request) => Response | Promise<Response>, 71 + ) { 59 72 return async function loggingHandler(req: Request): Promise<Response> { 60 73 const start = Date.now(); 61 - 74 + 62 75 try { 63 76 const response = await handler(req); 64 77 logRequest(req, start, response); ··· 67 80 const duration = Date.now() - start; 68 81 const url = new URL(req.url); 69 82 const message = error instanceof Error ? error.message : String(error); 70 - const methodColored = `${getMethodColor(req.method)}${req.method}${colors.reset}`; 71 - const pathColored = `${colors.bright}${url.pathname}${url.search}${colors.reset}`; 83 + const methodColored = `${ 84 + getMethodColor(req.method) 85 + }${req.method}${colors.reset}`; 86 + const pathColored = 87 + `${colors.bright}${url.pathname}${url.search}${colors.reset}`; 72 88 const errorColored = `${colors.red}ERROR${colors.reset}`; 73 89 const durationColored = `${colors.red}${duration}ms${colors.reset}`; 74 - 75 - console.error(`${methodColored} ${pathColored} ${errorColored} ${durationColored} ${colors.red}- ${message}${colors.reset}`); 76 - 90 + 91 + console.error( 92 + `${methodColored} ${pathColored} ${errorColored} ${durationColored} ${colors.red}- ${message}${colors.reset}`, 93 + ); 94 + 77 95 // Return a generic 500 error 78 96 const response = new Response("Internal Server Error", { status: 500 }); 79 97 logRequest(req, start, response); 80 98 return response; 81 99 } 82 100 }; 83 - } 101 + }
+1 -1
frontend/src/main.ts
··· 15 15 onListen: ({ port, hostname }) => 16 16 console.log(`Frontend server running on http://${hostname}:${port}`), 17 17 }, 18 - handler 18 + handler, 19 19 );
+4 -4
frontend/src/routes/middleware.ts
··· 1 - import { sessionStore, atprotoClient } from "../config.ts"; 1 + import { atprotoClient, sessionStore } from "../config.ts"; 2 2 import { recordBlobToCdnUrl } from "@slices/client"; 3 3 import { getSliceActor } from "../lib/api.ts"; 4 4 ··· 35 35 // Fallback to Bluesky profile for avatar if not found in slices profile 36 36 if (!currentUser.avatar) { 37 37 try { 38 - const profileRecords = 39 - await atprotoClient.app.bsky.actor.profile.getRecords({ 38 + const profileRecords = await atprotoClient.app.bsky.actor.profile 39 + .getRecords({ 40 40 where: { 41 41 did: { eq: currentUser.sub }, 42 42 }, ··· 49 49 currentUser.avatar = recordBlobToCdnUrl( 50 50 profileRecord, 51 51 profileRecord.value.avatar, 52 - "avatar" 52 + "avatar", 53 53 ); 54 54 } 55 55 }
+13 -2
frontend/src/routes/mod.ts
··· 2 2 import { landingRoutes } from "../features/landing/handlers.tsx"; 3 3 import { authRoutes } from "../features/auth/handlers.tsx"; 4 4 import { dashboardRoutes } from "../features/dashboard/handlers.tsx"; 5 - import { overviewRoutes, settingsRoutes as sliceSettingsRoutes, lexiconRoutes, recordsRoutes, codegenRoutes, oauthRoutes, apiDocsRoutes, syncRoutes, syncLogsRoutes, jetstreamRoutes } from "../features/slices/mod.ts"; 5 + import { 6 + apiDocsRoutes, 7 + codegenRoutes, 8 + jetstreamRoutes, 9 + lexiconRoutes, 10 + oauthRoutes, 11 + overviewRoutes, 12 + recordsRoutes, 13 + settingsRoutes as sliceSettingsRoutes, 14 + syncLogsRoutes, 15 + syncRoutes, 16 + } from "../features/slices/mod.ts"; 6 17 import { settingsRoutes } from "../features/settings/handlers.tsx"; 7 18 import { docsRoutes } from "../features/docs/handlers.tsx"; 8 19 ··· 33 44 ...syncRoutes, 34 45 ...syncLogsRoutes, 35 46 ...jetstreamRoutes, 36 - ]; 47 + ];
+19 -13
frontend/src/routes/slice-middleware.ts
··· 1 - import { atprotoClient } from "../config.ts"; 1 + import { publicClient } from "../config.ts"; 2 2 import { buildAtUri } from "../utils/at-uri.ts"; 3 3 import { getSlice } from "../lib/api.ts"; 4 4 import type { AuthenticatedUser } from "./middleware.ts"; ··· 31 31 export async function withSliceAccess( 32 32 context: AuthContext, 33 33 handle: string, 34 - sliceId: string 34 + sliceId: string, 35 35 ): Promise<SliceRequestContext> { 36 36 // First resolve the handle to a DID 37 37 let profileDid: string; 38 38 try { 39 - const actors = await atprotoClient.getActors({ 39 + const actors = await publicClient.getActors({ 40 40 where: { handle: { eq: handle } }, 41 41 }); 42 42 ··· 46 46 sliceContext: { 47 47 slice: null, 48 48 sliceId, 49 - sliceUri: '', 49 + sliceUri: "", 50 50 handle, 51 - profileDid: '', 51 + profileDid: "", 52 52 hasAccess: false, 53 53 }, 54 54 }; ··· 62 62 sliceContext: { 63 63 slice: null, 64 64 sliceId, 65 - sliceUri: '', 65 + sliceUri: "", 66 66 handle, 67 - profileDid: '', 67 + profileDid: "", 68 68 hasAccess: false, 69 69 }, 70 70 }; ··· 77 77 }); 78 78 79 79 try { 80 - const slice = await getSlice(atprotoClient, sliceUri); 80 + const slice = await getSlice(publicClient, sliceUri); 81 81 82 82 // User has access if they own the slice 83 83 const hasAccess = context.currentUser.isAuthenticated && 84 - context.currentUser.sub === profileDid; 84 + context.currentUser.sub === profileDid; 85 85 86 86 return { 87 87 ...context, ··· 119 119 }; 120 120 121 121 try { 122 - const stats = await atprotoClient.network.slices.slice.stats({ slice: sliceUri }); 122 + const stats = await publicClient.network.slices.slice.stats({ 123 + slice: sliceUri, 124 + }); 123 125 124 126 if (!stats.success) { 125 127 return defaultStats; ··· 141 143 } 142 144 } 143 145 144 - export function requireSliceAccess(context: SliceRequestContext): Response | null { 146 + export function requireSliceAccess( 147 + context: SliceRequestContext, 148 + ): Response | null { 145 149 if (!context.sliceContext) { 146 - return new Response("Internal error: slice context not initialized", { status: 500 }); 150 + return new Response("Internal error: slice context not initialized", { 151 + status: 500, 152 + }); 147 153 } 148 154 149 155 // If profile DID is empty, the handle wasn't found ··· 166 172 } 167 173 168 174 return null; 169 - } 175 + }
+109
frontend/src/shared/fragments/ActivitySparkline.tsx
··· 1 + interface ActivitySparklineProps { 2 + data?: number[]; 3 + width?: number; 4 + height?: number; 5 + className?: string; 6 + } 7 + 8 + export function ActivitySparkline({ 9 + data = [], 10 + width = 100, 11 + height = 30, 12 + className = "", 13 + }: ActivitySparklineProps) { 14 + // Generate mock data if none provided (for demo purposes) 15 + const sparklineData = data.length > 0 ? data : generateMockData(); 16 + 17 + // Calculate the path for the sparkline 18 + const max = Math.max(...sparklineData, 1); 19 + const min = Math.min(...sparklineData, 0); 20 + const range = max - min || 1; 21 + 22 + // Create SVG path points 23 + const points = sparklineData.map((value, index) => { 24 + const x = (index / (sparklineData.length - 1)) * width; 25 + const y = height - ((value - min) / range) * height; 26 + return `${x},${y}`; 27 + }).join(" "); 28 + 29 + // Create area path for gradient fill 30 + const areaPath = `M 0,${height} L ${points} L ${width},${height} Z`; 31 + 32 + return ( 33 + <div className={`inline-flex items-center ${className}`}> 34 + <svg 35 + width={width} 36 + height={height} 37 + className="overflow-visible" 38 + viewBox={`0 0 ${width} ${height}`} 39 + > 40 + {/* Gradient definition */} 41 + <defs> 42 + <linearGradient 43 + id="sparklineGradient" 44 + x1="0%" 45 + y1="0%" 46 + x2="0%" 47 + y2="100%" 48 + > 49 + <stop 50 + offset="0%" 51 + style={{ stopColor: "#3b82f6", stopOpacity: 0.3 }} 52 + /> 53 + <stop 54 + offset="100%" 55 + style={{ stopColor: "#3b82f6", stopOpacity: 0.05 }} 56 + /> 57 + </linearGradient> 58 + </defs> 59 + 60 + {/* Area fill */} 61 + <path 62 + d={areaPath} 63 + fill="url(#sparklineGradient)" 64 + strokeWidth="0" 65 + /> 66 + 67 + {/* Line */} 68 + <polyline 69 + points={points} 70 + fill="none" 71 + stroke="#3b82f6" 72 + strokeWidth="1.5" 73 + strokeLinecap="round" 74 + strokeLinejoin="round" 75 + /> 76 + 77 + {/* Dot at the end */} 78 + <circle 79 + cx={width} 80 + cy={height - 81 + ((sparklineData[sparklineData.length - 1] - min) / range) * height} 82 + r="2" 83 + fill="#3b82f6" 84 + /> 85 + </svg> 86 + 87 + {/* Activity label */} 88 + <div className="ml-2 text-xs text-zinc-500"> 89 + {sparklineData[sparklineData.length - 1]} ops/min 90 + </div> 91 + </div> 92 + ); 93 + } 94 + 95 + // Generate mock data for demonstration 96 + function generateMockData(): number[] { 97 + const points = 20; 98 + const data: number[] = []; 99 + let lastValue = Math.random() * 50 + 10; 100 + 101 + for (let i = 0; i < points; i++) { 102 + // Random walk with some smoothing 103 + const change = (Math.random() - 0.5) * 20; 104 + lastValue = Math.max(0, Math.min(100, lastValue + change)); 105 + data.push(Math.round(lastValue)); 106 + } 107 + 108 + return data; 109 + }
+1 -1
frontend/src/shared/fragments/ActorAvatar.tsx
··· 26 26 ) 27 27 : <DefaultAvatar size={size} className={className} /> 28 28 ); 29 - } 29 + }
+39 -14
frontend/src/shared/fragments/AvatarInput.tsx
··· 16 16 Avatar 17 17 </label> 18 18 <label htmlFor="avatar" className="cursor-pointer"> 19 - <div className="border rounded-full border-zinc-300 w-16 h-16 mx-auto mb-2 relative hover:border-zinc-400 transition-colors"> 19 + <div className="border rounded-full border-zinc-300 w-16 h-16 mb-2 relative hover:border-zinc-400 transition-colors"> 20 20 <div className="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 21 - <svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> 22 - <path fillRule="evenodd" d="M4 5a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V7a2 2 0 00-2-2h-1.586a1 1 0 01-.707-.293l-1.121-1.121A2 2 0 0011.172 3H8.828a2 2 0 00-1.414.586L6.293 4.707A1 1 0 015.586 5H4zm6 9a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" /> 21 + <svg 22 + className="w-3 h-3 text-white" 23 + fill="currentColor" 24 + viewBox="0 0 20 20" 25 + > 26 + <path 27 + fillRule="evenodd" 28 + d="M4 5a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V7a2 2 0 00-2-2h-1.586a1 1 0 01-.707-.293l-1.121-1.121A2 2 0 0011.172 3H8.828a2 2 0 00-1.414.586L6.293 4.707A1 1 0 015.586 5H4zm6 9a3 3 0 100-6 3 3 0 000 6z" 29 + clipRule="evenodd" 30 + /> 23 31 </svg> 24 32 </div> 25 - <div id="image-preview" className="w-full h-full rounded-full overflow-hidden"> 26 - {profile ? ( 27 - <ActorAvatar profile={profile} size={64} className="w-full h-full" /> 28 - ) : ( 29 - <div className="w-full h-full bg-zinc-100 flex items-center justify-center"> 30 - <svg className="w-8 h-8 text-zinc-400" fill="currentColor" viewBox="0 0 20 20"> 31 - <path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" /> 32 - </svg> 33 - </div> 34 - )} 33 + <div 34 + id="image-preview" 35 + className="w-full h-full rounded-full overflow-hidden" 36 + > 37 + {profile 38 + ? ( 39 + <ActorAvatar 40 + profile={profile} 41 + size={64} 42 + className="w-full h-full" 43 + /> 44 + ) 45 + : ( 46 + <div className="w-full h-full bg-zinc-100 flex items-center justify-center"> 47 + <svg 48 + className="w-8 h-8 text-zinc-400" 49 + fill="currentColor" 50 + viewBox="0 0 20 20" 51 + > 52 + <path 53 + fillRule="evenodd" 54 + d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" 55 + clipRule="evenodd" 56 + /> 57 + </svg> 58 + </div> 59 + )} 35 60 </div> 36 61 </div> 37 62 </label> ··· 51 76 /> 52 77 </div> 53 78 ); 54 - } 79 + }
+4 -2
frontend/src/shared/fragments/Breadcrumb.tsx
··· 5 5 label?: string; 6 6 } 7 7 8 - export function Breadcrumb({ href, label = "Back to Slices" }: BreadcrumbProps) { 8 + export function Breadcrumb( 9 + { href, label = "Back to Slices" }: BreadcrumbProps, 10 + ) { 9 11 return ( 10 12 <div className="mb-2"> 11 13 <a ··· 17 19 </a> 18 20 </div> 19 21 ); 20 - } 22 + }
+21 -20
frontend/src/shared/fragments/Button.tsx
··· 1 1 import type { JSX } from "preact"; 2 2 import { cn } from "../../utils/cn.ts"; 3 3 4 - type ButtonVariant = 5 - | "primary" // blue 6 - | "secondary" // gray 7 - | "danger" // red 8 - | "success" // green 9 - | "warning" // orange 10 - | "purple" // purple 11 - | "indigo" // indigo 12 - | "ghost"; // transparent with hover 4 + type ButtonVariant = 5 + | "primary" // blue 6 + | "secondary" // gray 7 + | "danger" // red 8 + | "success" // green 9 + | "warning" // orange 10 + | "purple" // purple 11 + | "indigo" // indigo 12 + | "ghost"; // transparent with hover 13 13 14 14 type ButtonSize = "sm" | "md" | "lg"; 15 15 16 - export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { 16 + export interface ButtonProps 17 + extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { 17 18 variant?: ButtonVariant; 18 19 size?: ButtonSize; 19 20 children: JSX.Element | JSX.Element[] | string; ··· 22 23 23 24 const variantClasses = { 24 25 primary: "bg-blue-500 hover:bg-blue-600 text-white", 25 - secondary: "bg-gray-500 hover:bg-gray-600 text-white", 26 + secondary: "bg-gray-500 hover:bg-gray-600 text-white", 26 27 danger: "bg-red-600 hover:bg-red-700 text-white", 27 28 success: "bg-green-500 hover:bg-green-600 text-white", 28 29 warning: "bg-orange-500 hover:bg-orange-600 text-white", ··· 33 34 34 35 const sizeClasses = { 35 36 sm: "px-3 py-1 text-sm", 36 - md: "px-4 py-2", 37 + md: "px-4 py-2", 37 38 lg: "px-6 py-2 font-medium", 38 39 }; 39 40 40 41 export function Button(props: ButtonProps): JSX.Element { 41 - const { 42 - variant = "primary", 43 - size = "md", 44 - children, 42 + const { 43 + variant = "primary", 44 + size = "md", 45 + children, 45 46 href, 46 47 class: classProp, 47 - ...rest 48 + ...rest 48 49 } = props; 49 - 50 + 50 51 const className = cn( 51 52 "rounded transition-colors inline-flex items-center", 52 53 variantClasses[variant], 53 54 sizeClasses[size], 54 - classProp 55 + classProp, 55 56 ); 56 57 57 58 if (href) { ··· 67 68 {children} 68 69 </button> 69 70 ); 70 - } 71 + }
+1 -1
frontend/src/shared/fragments/DefaultAvatar.tsx
··· 31 31 </g> 32 32 </svg> 33 33 ); 34 - } 34 + }
+1 -1
frontend/src/shared/fragments/EmptyState.tsx
··· 35 35 } 36 36 37 37 return content; 38 - } 38 + }
+5 -3
frontend/src/shared/fragments/FlashMessage.tsx
··· 4 4 className?: string; 5 5 } 6 6 7 - export function FlashMessage({ type, message, className = "" }: FlashMessageProps) { 7 + export function FlashMessage( 8 + { type, message, className = "" }: FlashMessageProps, 9 + ) { 8 10 const baseClasses = "px-4 py-3 mb-4 border"; 9 - const typeClasses = type === "success" 11 + const typeClasses = type === "success" 10 12 ? "bg-green-50 border-green-200 text-green-700" 11 13 : "bg-red-50 border-red-200 text-red-700"; 12 14 const icon = type === "success" ? "✅" : "❌"; ··· 16 18 {icon} {message} 17 19 </div> 18 20 ); 19 - } 21 + }
+5 -5
frontend/src/shared/fragments/Input.tsx
··· 10 10 const { class: classProp, label, error, ...rest } = props; 11 11 const className = cn( 12 12 "block w-full border border-zinc-300 rounded-md px-3 py-2", 13 - error ? "border-red-300 focus:border-red-500 focus:ring-red-500" : "focus:border-zinc-500 focus:ring-zinc-500", 13 + error 14 + ? "border-red-300 focus:border-red-500 focus:ring-red-500" 15 + : "focus:border-zinc-500 focus:ring-zinc-500", 14 16 classProp, 15 17 ); 16 18 ··· 23 25 </label> 24 26 )} 25 27 <input class={className} {...rest} /> 26 - {error && ( 27 - <p className="mt-1 text-sm text-red-600">{error}</p> 28 - )} 28 + {error && <p className="mt-1 text-sm text-red-600">{error}</p>} 29 29 </div> 30 30 ); 31 - } 31 + }
+104 -75
frontend/src/shared/fragments/Layout.tsx
··· 27 27 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 28 28 <title>{title}</title> 29 29 <meta name="description" content={description} /> 30 - 30 + 31 31 {/* Open Graph / Facebook */} 32 32 <meta property="og:type" content="website" /> 33 33 <meta property="og:title" content={title} /> 34 34 <meta property="og:description" content={description} /> 35 - 35 + 36 36 {/* Twitter */} 37 37 <meta property="twitter:card" content="summary_large_image" /> 38 38 <meta property="twitter:title" content={title} /> ··· 65 65 <nav className="sm:fixed sm:top-0 sm:left-0 sm:right-0 h-14 z-50 bg-white border-b border-zinc-200"> 66 66 <div className="mx-auto max-w-5xl h-full flex items-center justify-between px-4"> 67 67 <div className="flex items-center space-x-4"> 68 - <a href="/" className="text-xl font-bold text-zinc-900 hover:text-zinc-700"> 68 + <a 69 + href="/" 70 + className="text-xl font-bold text-zinc-900 hover:text-zinc-700" 71 + > 69 72 Slices 70 73 </a> 71 74 <a ··· 76 79 </a> 77 80 </div> 78 81 <div className="flex items-center space-x-2"> 79 - {currentUser?.isAuthenticated ? ( 80 - <div className="flex items-center space-x-2"> 81 - <a 82 - href={`/profile/${currentUser.handle}`} 83 - className="px-3 py-1.5 text-sm text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded-md transition-colors" 84 - > 85 - Dashboard 86 - </a> 87 - <div className="relative"> 88 - <button 89 - type="button" 90 - className="flex items-center p-1 rounded-full hover:bg-zinc-100 transition-colors" 91 - _="on click toggle .hidden on #avatar-dropdown 82 + {currentUser?.isAuthenticated 83 + ? ( 84 + <div className="flex items-center space-x-2"> 85 + <a 86 + href={`/profile/${currentUser.handle}`} 87 + className="px-3 py-1.5 text-sm text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded-md transition-colors" 88 + > 89 + Dashboard 90 + </a> 91 + <div className="relative"> 92 + <button 93 + type="button" 94 + className="flex items-center p-1 rounded-full hover:bg-zinc-100 transition-colors" 95 + _="on click toggle .hidden on #avatar-dropdown 92 96 on click from document 93 97 if not me.contains(event.target) and not #avatar-dropdown.contains(event.target) 94 98 add .hidden to #avatar-dropdown" 95 - > 96 - {currentUser.avatar ? ( 97 - <img 98 - src={currentUser.avatar} 99 - alt="Profile avatar" 100 - className="w-8 h-8 rounded-full" 101 - /> 102 - ) : ( 103 - <div className="w-8 h-8 bg-zinc-300 rounded-full flex items-center justify-center"> 104 - <span className="text-sm text-zinc-600 font-medium"> 105 - {currentUser.handle?.charAt(0).toUpperCase() || "U"} 106 - </span> 107 - </div> 108 - )} 109 - </button> 99 + > 100 + {currentUser.avatar 101 + ? ( 102 + <img 103 + src={currentUser.avatar} 104 + alt="Profile avatar" 105 + className="w-8 h-8 rounded-full" 106 + /> 107 + ) 108 + : ( 109 + <div className="w-8 h-8 bg-zinc-300 rounded-full flex items-center justify-center"> 110 + <span className="text-sm text-zinc-600 font-medium"> 111 + {currentUser.handle?.charAt(0) 112 + .toUpperCase() || "U"} 113 + </span> 114 + </div> 115 + )} 116 + </button> 110 117 111 - <div id="avatar-dropdown" className="hidden absolute right-0 mt-2 w-64 bg-white border border-zinc-200 rounded-md shadow-lg z-50"> 112 - <div className="py-1"> 113 - <div className="px-4 py-3 border-b border-zinc-100"> 114 - <div className="text-sm font-medium text-zinc-900"> 115 - {currentUser.displayName || currentUser.handle || "User"} 116 - </div> 117 - <div className="text-sm text-zinc-500"> 118 - {currentUser.handle ? `@${currentUser.handle}` : ""} 118 + <div 119 + id="avatar-dropdown" 120 + className="hidden absolute right-0 mt-2 w-64 bg-white border border-zinc-200 rounded-md shadow-lg z-50" 121 + > 122 + <div className="py-1"> 123 + <div className="px-4 py-3 border-b border-zinc-100"> 124 + <div className="text-sm font-medium text-zinc-900"> 125 + {currentUser.displayName || 126 + currentUser.handle || "User"} 127 + </div> 128 + <div className="text-sm text-zinc-500"> 129 + {currentUser.handle 130 + ? `@${currentUser.handle}` 131 + : ""} 132 + </div> 133 + </div> 134 + <a 135 + href="/settings" 136 + className="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors" 137 + > 138 + Settings 139 + </a> 140 + <form 141 + method="post" 142 + action="/logout" 143 + className="block" 144 + > 145 + <button 146 + type="submit" 147 + className="w-full text-left px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors" 148 + > 149 + Sign out 150 + </button> 151 + </form> 119 152 </div> 120 153 </div> 121 - <a 122 - href="/settings" 123 - className="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors" 124 - > 125 - Settings 126 - </a> 127 - <form method="post" action="/logout" className="block"> 128 - <button 129 - type="submit" 130 - className="w-full text-left px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors" 131 - > 132 - Sign out 133 - </button> 134 - </form> 135 154 </div> 136 155 </div> 156 + ) 157 + : ( 158 + <div className="flex items-center space-x-2"> 159 + <button 160 + type="button" 161 + className="px-3 py-1.5 text-sm text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded-md transition-colors" 162 + _="on click call #waitlist-modal.showModal()" 163 + > 164 + Join Waitlist 165 + </button> 166 + <a 167 + href="/login" 168 + className="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors" 169 + > 170 + Sign in 171 + </a> 137 172 </div> 138 - </div> 139 - ) : ( 140 - <div className="flex items-center space-x-2"> 141 - <a 142 - href="/login" 143 - className="px-4 py-1.5 text-sm bg-zinc-900 hover:bg-zinc-800 text-white rounded-md transition-colors" 144 - > 145 - Sign in 146 - </a> 147 - </div> 148 - )} 173 + )} 149 174 </div> 150 175 </div> 151 176 </nav> 152 177 )} 153 - <div 154 - className={`min-h-screen flex flex-col ${fullWidth ? '' : 'max-w-5xl mx-auto sm:border-x border-zinc-200'}`} 178 + <div 179 + className={`min-h-screen flex flex-col ${ 180 + fullWidth ? "" : "max-w-5xl mx-auto sm:border-x border-zinc-200" 181 + }`} 155 182 style={backgroundStyle} 156 183 > 157 - {showNavigation ? ( 158 - <main className="flex-1 sm:pt-14"> 159 - {children} 160 - </main> 161 - ) : ( 162 - <main className="flex-1"> 163 - {children} 164 - </main> 165 - )} 184 + {showNavigation 185 + ? ( 186 + <main className="flex-1 sm:pt-14"> 187 + {children} 188 + </main> 189 + ) 190 + : ( 191 + <main className="flex-1"> 192 + {children} 193 + </main> 194 + )} 166 195 </div> 167 196 </body> 168 197 </html>
+1 -1
frontend/src/shared/fragments/LogLevelBadge.tsx
··· 19 19 {level.toUpperCase()} 20 20 </span> 21 21 ); 22 - } 22 + }
+4 -4
frontend/src/shared/fragments/LogViewer.tsx
··· 7 7 formatTimestamp?: (timestamp: string) => string; 8 8 } 9 9 10 - export function LogViewer({ 11 - logs, 10 + export function LogViewer({ 11 + logs, 12 12 emptyMessage = "No logs available.", 13 - formatTimestamp = (timestamp) => new Date(timestamp).toLocaleString() 13 + formatTimestamp = (timestamp) => new Date(timestamp).toLocaleString(), 14 14 }: LogViewerProps) { 15 15 if (logs.length === 0) { 16 16 return ( ··· 88 88 </div> 89 89 </div> 90 90 ); 91 - } 91 + }
+41
frontend/src/shared/fragments/Modal.tsx
··· 1 + import { ComponentChildren } from "preact"; 2 + 3 + interface ModalProps { 4 + title: string; 5 + description?: string; 6 + children: ComponentChildren; 7 + onClose?: string; // Hyperscript for close action, defaults to clearing modal-container 8 + } 9 + 10 + export function Modal({ 11 + title, 12 + description, 13 + children, 14 + onClose = "on click set #modal-container's innerHTML to ''", 15 + }: ModalProps) { 16 + return ( 17 + <div 18 + className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" 19 + _={`on click if event.target === me then ${ 20 + onClose.replace("on click ", "") 21 + }`} 22 + > 23 + <div className="bg-white rounded-lg p-6 max-w-3xl w-full max-h-[90vh] overflow-y-auto"> 24 + <div className="flex justify-between items-start mb-4"> 25 + <div> 26 + <h2 className="text-2xl font-semibold">{title}</h2> 27 + {description && <p className="text-zinc-600 mt-2">{description}</p>} 28 + </div> 29 + <button 30 + type="button" 31 + _={onClose} 32 + className="text-gray-400 hover:text-gray-600 text-2xl leading-none" 33 + > 34 + 35 + </button> 36 + </div> 37 + {children} 38 + </div> 39 + </div> 40 + ); 41 + }
+1 -1
frontend/src/shared/fragments/PageHeader.tsx
··· 10 10 {children && <div className="flex items-center gap-4">{children}</div>} 11 11 </div> 12 12 ); 13 - } 13 + }
+7 -6
frontend/src/shared/fragments/Select.tsx
··· 1 1 import type { JSX } from "preact"; 2 2 import { cn } from "../../utils/cn.ts"; 3 3 4 - export interface SelectProps extends JSX.SelectHTMLAttributes<HTMLSelectElement> { 4 + export interface SelectProps 5 + extends JSX.SelectHTMLAttributes<HTMLSelectElement> { 5 6 label?: string; 6 7 error?: string; 7 8 } ··· 10 11 const { class: classProp, label, error, children, ...rest } = props; 11 12 const className = cn( 12 13 "block w-full border border-gray-300 rounded-md px-3 py-2", 13 - error ? "border-red-300 focus:border-red-500 focus:ring-red-500" : "focus:border-blue-500 focus:ring-blue-500", 14 + error 15 + ? "border-red-300 focus:border-red-500 focus:ring-red-500" 16 + : "focus:border-blue-500 focus:ring-blue-500", 14 17 classProp, 15 18 ); 16 19 ··· 24 27 <select class={className} {...rest}> 25 28 {children} 26 29 </select> 27 - {error && ( 28 - <p className="mt-1 text-sm text-red-600">{error}</p> 29 - )} 30 + {error && <p className="mt-1 text-sm text-red-600">{error}</p>} 30 31 </div> 31 32 ); 32 - } 33 + }
+56
frontend/src/shared/fragments/SliceCard.tsx
··· 1 + import { ActorAvatar } from "./ActorAvatar.tsx"; 2 + import { ActivitySparkline } from "./ActivitySparkline.tsx"; 3 + import { timeAgo } from "../../utils/time.ts"; 4 + import { buildSliceUrlFromView } from "../../utils/slice-params.ts"; 5 + import type { NetworkSlicesSliceDefsSliceView } from "../../client.ts"; 6 + 7 + interface SliceCardProps { 8 + slice: NetworkSlicesSliceDefsSliceView; 9 + } 10 + 11 + export function SliceCard({ slice }: SliceCardProps) { 12 + const rkey = slice.uri.split("/").pop() || ""; 13 + const sliceUrl = buildSliceUrlFromView(slice, rkey); 14 + 15 + return ( 16 + <a href={sliceUrl} className="block"> 17 + <div className="bg-white border border-zinc-200 rounded-lg p-4 hover:border-zinc-300 hover:shadow-sm transition-all cursor-pointer"> 18 + <div className="flex items-start justify-between"> 19 + {/* Left side - avatar and content */} 20 + <div className="flex items-start space-x-3 flex-1"> 21 + <ActorAvatar profile={slice.creator} size={40} /> 22 + <div className="flex-1 min-w-0"> 23 + <div className="flex items-center space-x-2 mb-1"> 24 + <span className="text-sm font-medium text-zinc-900"> 25 + {slice.creator.displayName || slice.creator.handle} 26 + </span> 27 + <span className="text-sm text-zinc-500"> 28 + @{slice.creator.handle} 29 + </span> 30 + <time 31 + className="text-sm text-zinc-400" 32 + dateTime={slice.createdAt} 33 + > 34 + {timeAgo(slice.createdAt)} 35 + </time> 36 + </div> 37 + <div className="group"> 38 + <h3 className="text-lg font-semibold text-zinc-900 group-hover:text-zinc-700 mb-1"> 39 + {slice.name} 40 + </h3> 41 + <p className="text-sm text-zinc-600 mb-2">{slice.domain}</p> 42 + </div> 43 + </div> 44 + </div> 45 + 46 + {/* Right side - activity sparkline */} 47 + { 48 + /* <div className="flex items-center ml-4 self-center"> 49 + <ActivitySparkline /> 50 + </div> */ 51 + } 52 + </div> 53 + </div> 54 + </a> 55 + ); 56 + }
+7 -6
frontend/src/shared/fragments/Textarea.tsx
··· 1 1 import type { JSX } from "preact"; 2 2 import { cn } from "../../utils/cn.ts"; 3 3 4 - export interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> { 4 + export interface TextareaProps 5 + extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> { 5 6 label?: string; 6 7 error?: string; 7 8 } ··· 10 11 const { class: classProp, label, error, ...rest } = props; 11 12 const className = cn( 12 13 "block w-full border border-zinc-300 rounded-md px-3 py-2", 13 - error ? "border-red-300 focus:border-red-500 focus:ring-red-500" : "focus:border-zinc-500 focus:ring-zinc-500", 14 + error 15 + ? "border-red-300 focus:border-red-500 focus:ring-red-500" 16 + : "focus:border-zinc-500 focus:ring-zinc-500", 14 17 classProp, 15 18 ); 16 19 ··· 23 26 </label> 24 27 )} 25 28 <textarea class={className} {...rest} /> 26 - {error && ( 27 - <p className="mt-1 text-sm text-red-600">{error}</p> 28 - )} 29 + {error && <p className="mt-1 text-sm text-red-600">{error}</p>} 29 30 </div> 30 31 ); 31 - } 32 + }
+13 -5
frontend/src/utils/client.ts
··· 11 11 // Helper function to get a slice-specific AT Protocol client 12 12 export function getSliceClient( 13 13 context: AuthContext, 14 - sliceId: string 14 + sliceId: string, 15 + sliceOwnerDid?: string, 15 16 ): AtProtoClient { 16 17 const API_URL = Deno.env.get("API_URL")!; 17 18 18 - if (!context.currentUser.sub) { 19 - throw new Error("User DID is required to create slice client"); 19 + // Use provided sliceOwnerDid or fall back to current user's DID 20 + const ownerDid = sliceOwnerDid || context.currentUser.sub; 21 + 22 + if (!ownerDid) { 23 + throw new Error("Owner DID is required to create slice client"); 20 24 } 21 25 22 26 const sliceUri = buildAtUri({ 23 - did: context.currentUser.sub, 27 + did: ownerDid, 24 28 collection: "network.slices.slice", 25 29 rkey: sliceId, 26 30 }); 27 - return new AtProtoClient(API_URL, sliceUri, atprotoClient.oauth); 31 + 32 + // Use authenticated client if user is authenticated, otherwise public client 33 + return context.currentUser.sub 34 + ? new AtProtoClient(API_URL, sliceUri, atprotoClient.oauth) 35 + : new AtProtoClient(API_URL, sliceUri); 28 36 }
+1 -1
frontend/src/utils/cn.ts
··· 3 3 4 4 export function cn(...inputs: ClassValue[]): string { 5 5 return twMerge(clsx(inputs)); 6 - } 6 + }
+1 -1
frontend/src/utils/htmx.ts
··· 11 11 "HX-Redirect": url, 12 12 }, 13 13 }); 14 - } 14 + }
+3 -3
frontend/src/utils/render.tsx
··· 14 14 headers?: Record<string, string>; 15 15 title?: string; 16 16 description?: string; 17 - } 17 + }, 18 18 ): Response { 19 19 const html = render(jsx); 20 - 20 + 21 21 const headers: Record<string, string> = { 22 22 "content-type": "text/html; charset=utf-8", 23 23 ...options?.headers, ··· 27 27 status: options?.status || 200, 28 28 headers, 29 29 }); 30 - } 30 + }
+15 -5
frontend/src/utils/slice-params.ts
··· 5 5 sliceId: string; 6 6 } 7 7 8 - export function extractSliceParams(params?: URLPatternResult): SliceParams | null { 8 + export function extractSliceParams( 9 + params?: URLPatternResult, 10 + ): SliceParams | null { 9 11 const handle = params?.pathname.groups.handle; 10 12 const sliceId = params?.pathname.groups.rkey; 11 13 ··· 16 18 return { handle, sliceId }; 17 19 } 18 20 19 - export function buildSliceUrl(handle: string, sliceId: string, path?: string): string { 21 + export function buildSliceUrl( 22 + handle: string, 23 + sliceId: string, 24 + path?: string, 25 + ): string { 20 26 const basePath = `/profile/${handle}/slice/${sliceId}`; 21 27 return path ? `${basePath}/${path}` : basePath; 22 28 } 23 29 24 30 // Helper to build slice URLs from slice view 25 - export function buildSliceUrlFromView(slice: NetworkSlicesSliceDefsSliceView, sliceId: string, path?: string): string { 26 - const handle = slice.creator?.handle || 'unknown'; 31 + export function buildSliceUrlFromView( 32 + slice: NetworkSlicesSliceDefsSliceView, 33 + sliceId: string, 34 + path?: string, 35 + ): string { 36 + const handle = slice.creator?.handle || "unknown"; 27 37 return buildSliceUrl(handle, sliceId, path); 28 - } 38 + }
+18 -1
frontend/src/utils/time.ts
··· 6 6 second: "2-digit", 7 7 fractionalSecondDigits: 3, 8 8 }); 9 - } 9 + } 10 + 11 + export function timeAgo(dateString: string): string { 12 + const now = new Date(); 13 + const date = new Date(dateString); 14 + const diffInMs = now.getTime() - date.getTime(); 15 + const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); 16 + const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); 17 + const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); 18 + 19 + if (diffInMinutes < 60) { 20 + return `${diffInMinutes}m ago`; 21 + } else if (diffInHours < 24) { 22 + return `${diffInHours}h ago`; 23 + } else { 24 + return `${diffInDays}d ago`; 25 + } 26 + }