Highly ambitious ATProtocol AppView service and sdks

consistent styling across the board, more shared components, oauth form bug fixes, dark mode support, and stuff

+2110 -1557
+16 -7
api/scripts/generate_typescript.ts
··· 66 66 * import { AtProtoClient } from "./generated_client.ts"; 67 67 * 68 68 * const client = new AtProtoClient( 69 - * 'https://slices-api.fly.dev', 69 + * 'https://api.slices.network', 70 70 * '${sliceUri}' 71 71 * ); 72 72 * ··· 126 126 * import { AtProtoClient } from "./generated_client.ts"; 127 127 * 128 128 * const client = new AtProtoClient( 129 - * 'https://slices-api.fly.dev', 129 + * 'https://api.slices.network', 130 130 * '${sliceUri}' 131 131 * ); 132 132 * ··· 168 168 * import { AtProtoClient } from "./generated_client.ts"; 169 169 * 170 170 * const client = new AtProtoClient( 171 - * 'https://slices-api.fly.dev', 171 + * 'https://api.slices.network', 172 172 * '${sliceUri}' 173 173 * ); 174 174 * ··· 511 511 isExported: true, 512 512 properties: [ 513 513 { name: "success", type: "boolean" }, 514 + { name: "message", type: "string" }, 515 + ], 516 + }); 517 + 518 + sourceFile.addInterface({ 519 + name: "OAuthOperationError", 520 + isExported: true, 521 + properties: [ 522 + { name: "success", type: "false" }, 514 523 { name: "message", type: "string" }, 515 524 ], 516 525 }); ··· 1380 1389 classDeclaration.addMethod({ 1381 1390 name: "createOAuthClient", 1382 1391 parameters: [{ name: "params", type: "CreateOAuthClientRequest" }], 1383 - returnType: "Promise<OAuthClientDetails>", 1392 + returnType: "Promise<OAuthClientDetails | OAuthOperationError>", 1384 1393 isAsync: true, 1385 1394 statements: [ 1386 1395 `const requestParams = { ...params, sliceUri: this.client.sliceUri };`, 1387 - `return await this.client.makeRequest<OAuthClientDetails>('network.slices.slice.createOAuthClient', 'POST', requestParams);`, 1396 + `return await this.client.makeRequest<OAuthClientDetails | OAuthOperationError>('network.slices.slice.createOAuthClient', 'POST', requestParams);`, 1388 1397 ], 1389 1398 }); 1390 1399 ··· 1401 1410 classDeclaration.addMethod({ 1402 1411 name: "updateOAuthClient", 1403 1412 parameters: [{ name: "params", type: "UpdateOAuthClientRequest" }], 1404 - returnType: "Promise<OAuthClientDetails>", 1413 + returnType: "Promise<OAuthClientDetails | OAuthOperationError>", 1405 1414 isAsync: true, 1406 1415 statements: [ 1407 1416 `const requestParams = { ...params, sliceUri: this.client.sliceUri };`, 1408 - `return await this.client.makeRequest<OAuthClientDetails>('network.slices.slice.updateOAuthClient', 'POST', requestParams);`, 1417 + `return await this.client.makeRequest<OAuthClientDetails | OAuthOperationError>('network.slices.slice.updateOAuthClient', 'POST', requestParams);`, 1409 1418 ], 1410 1419 }); 1411 1420
+42 -6
api/src/handler_oauth_clients.rs
··· 87 87 State(state): State<AppState>, 88 88 headers: HeaderMap, 89 89 ExtractJson(request): ExtractJson<CreateOAuthClientRequest>, 90 - ) -> Result<Json<OAuthClientDetails>, AppError> { 90 + ) -> Result<Json<serde_json::Value>, AppError> { 91 91 // Debug log the incoming request 92 92 tracing::debug!("Incoming OAuth client registration request: {:?}", request); 93 93 ··· 151 151 if !status.is_success() { 152 152 let error_text = aip_response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); 153 153 tracing::error!("AIP registration failed with status {}: {}", status, error_text); 154 - return Err(AppError::Internal(format!("AIP registration failed with status {}: {}", status, error_text))); 154 + 155 + // Try to parse the error response as JSON to get more details 156 + let detailed_error = if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) { 157 + if let Some(error_desc) = error_json.get("error_description").and_then(|v| v.as_str()) { 158 + error_desc.to_string() 159 + } else if let Some(error) = error_json.get("error").and_then(|v| v.as_str()) { 160 + error.to_string() 161 + } else { 162 + error_text 163 + } 164 + } else { 165 + error_text 166 + }; 167 + 168 + // Return success=false with detailed error message instead of HTTP error 169 + return Ok(Json(serde_json::json!({ 170 + "success": false, 171 + "message": detailed_error 172 + }))); 155 173 } 156 174 157 175 tracing::debug!("Parsing AIP response JSON..."); ··· 208 226 created_by_did: oauth_client.created_by_did, 209 227 }; 210 228 211 - Ok(Json(response)) 229 + Ok(Json(serde_json::to_value(response).unwrap())) 212 230 } 213 231 214 232 pub async fn get_oauth_clients( ··· 353 371 State(state): State<AppState>, 354 372 headers: HeaderMap, 355 373 ExtractJson(request): ExtractJson<UpdateOAuthClientRequest>, 356 - ) -> Result<Json<OAuthClientDetails>, AppError> { 374 + ) -> Result<Json<serde_json::Value>, AppError> { 357 375 let client_id = request.client_id.clone(); 358 376 359 377 // Extract and verify authentication ··· 406 424 if !status.is_success() { 407 425 let error_text = aip_response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); 408 426 tracing::error!("AIP update failed with status {}: {}", status, error_text); 409 - return Err(AppError::Internal(format!("AIP update failed with status {}: {}", status, error_text))); 427 + 428 + // Try to parse the error response as JSON to get more details 429 + let detailed_error = if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) { 430 + if let Some(error_desc) = error_json.get("error_description").and_then(|v| v.as_str()) { 431 + error_desc.to_string() 432 + } else if let Some(error) = error_json.get("error").and_then(|v| v.as_str()) { 433 + error.to_string() 434 + } else { 435 + error_text 436 + } 437 + } else { 438 + error_text 439 + }; 440 + 441 + // Return success=false with detailed error message instead of HTTP error 442 + return Ok(Json(serde_json::json!({ 443 + "success": false, 444 + "message": detailed_error 445 + }))); 410 446 } 411 447 412 448 // Parse the response ··· 440 476 created_by_did: oauth_client.created_by_did, 441 477 }; 442 478 443 - Ok(Json(response)) 479 + Ok(Json(serde_json::to_value(response).unwrap())) 444 480 } 445 481 446 482 pub async fn delete_oauth_client(
+15 -14
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-16 01:17:57 UTC 2 + // Generated at: 2025-09-16 21:02:30 UTC 3 3 // Lexicons: 9 4 4 5 5 /** ··· 8 8 * import { AtProtoClient } from "./generated_client.ts"; 9 9 * 10 10 * const client = new AtProtoClient( 11 - * 'https://slices-api.fly.dev', 11 + * 'https://api.slices.network', 12 12 * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z' 13 13 * ); 14 14 * ··· 255 255 256 256 export interface DeleteOAuthClientResponse { 257 257 success: boolean; 258 + message: string; 259 + } 260 + 261 + export interface OAuthOperationError { 262 + success: false; 258 263 message: string; 259 264 } 260 265 ··· 743 748 744 749 async createOAuthClient( 745 750 params: CreateOAuthClientRequest 746 - ): Promise<OAuthClientDetails> { 751 + ): Promise<OAuthClientDetails | OAuthOperationError> { 747 752 const requestParams = { ...params, sliceUri: this.client.sliceUri }; 748 - return await this.client.makeRequest<OAuthClientDetails>( 749 - "network.slices.slice.createOAuthClient", 750 - "POST", 751 - requestParams 752 - ); 753 + return await this.client.makeRequest< 754 + OAuthClientDetails | OAuthOperationError 755 + >("network.slices.slice.createOAuthClient", "POST", requestParams); 753 756 } 754 757 755 758 async getOAuthClients(): Promise<ListOAuthClientsResponse> { ··· 763 766 764 767 async updateOAuthClient( 765 768 params: UpdateOAuthClientRequest 766 - ): Promise<OAuthClientDetails> { 769 + ): Promise<OAuthClientDetails | OAuthOperationError> { 767 770 const requestParams = { ...params, sliceUri: this.client.sliceUri }; 768 - return await this.client.makeRequest<OAuthClientDetails>( 769 - "network.slices.slice.updateOAuthClient", 770 - "POST", 771 - requestParams 772 - ); 771 + return await this.client.makeRequest< 772 + OAuthClientDetails | OAuthOperationError 773 + >("network.slices.slice.updateOAuthClient", "POST", requestParams); 773 774 } 774 775 775 776 async deleteOAuthClient(
+14 -22
frontend/src/features/auth/templates/fragments/LoginForm.tsx
··· 1 + import { Button } from "../../../../shared/fragments/Button.tsx"; 2 + import { Input } from "../../../../shared/fragments/Input.tsx"; 3 + 1 4 interface LoginFormProps { 2 5 error?: string; 3 6 } ··· 5 8 export function LoginForm({ error }: LoginFormProps) { 6 9 return ( 7 10 <form method="post" action="/oauth/authorize" className="space-y-2"> 8 - <div> 9 - <label htmlFor="loginHint" className="sr-only"> 10 - Handle 11 - </label> 12 - <input 13 - type="text" 14 - id="loginHint" 15 - name="loginHint" 16 - placeholder="Enter your handle or PDS host" 17 - className="w-full px-3 py-2 bg-white text-zinc-900 border border-zinc-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" 18 - required 19 - /> 20 - </div> 11 + <Input 12 + id="loginHint" 13 + name="loginHint" 14 + placeholder="Enter your handle or PDS host" 15 + required 16 + className="w-full border-zinc-400 dark:border-zinc-700" 17 + /> 21 18 22 - <button 23 - type="submit" 24 - className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors" 25 - > 19 + <Button type="submit" variant="blue" className="w-full justify-center"> 26 20 Login 27 - </button> 21 + </Button> 28 22 29 23 {error && ( 30 - <div className="h-4"> 31 - <div className="text-sm font-mono text-white"> 32 - {error} 33 - </div> 24 + <div className="text-white text-sm bg-zinc-950/70 p-4 font-mono rounded"> 25 + {error} 34 26 </div> 35 27 )} 36 28 </form>
+44 -49
frontend/src/features/dashboard/templates/DashboardPage.tsx
··· 3 3 import { EmptyState } from "../../../shared/fragments/EmptyState.tsx"; 4 4 import { SliceCard } from "../../../shared/fragments/SliceCard.tsx"; 5 5 import { ActorAvatar } from "../../../shared/fragments/ActorAvatar.tsx"; 6 + import { Card } from "../../../shared/fragments/Card.tsx"; 7 + import { Text } from "../../../shared/fragments/Text.tsx"; 6 8 import { FileText } from "lucide-preact"; 7 9 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 8 10 import type { ··· 24 26 const displayName = profile?.displayName || profile?.handle || "User"; 25 27 26 28 // Check if current user is viewing their own profile 27 - const isOwnProfile = currentUser?.isAuthenticated && 28 - currentUser?.handle === profile?.handle; 29 + const isOwnProfile = 30 + currentUser?.isAuthenticated && currentUser?.handle === profile?.handle; 29 31 30 32 return ( 31 33 <Layout title="Slices" currentUser={currentUser}> ··· 34 36 {profile && ( 35 37 <div className="flex flex-col mb-8"> 36 38 <ActorAvatar profile={profile} size={64} /> 37 - <p className="text-2xl font-bold text-zinc-900 mt-2"> 39 + <Text as="p" size="2xl" className="font-bold mt-2"> 38 40 {displayName} 39 - </p> 40 - <p className="text-zinc-600">@{profile.handle}</p> 41 + </Text> 42 + <Text as="p" variant="secondary"> 43 + @{profile.handle} 44 + </Text> 41 45 {profile.description && ( 42 - <p className="text-zinc-600 mt-2 max-w-lg"> 46 + <Text as="p" variant="secondary" className="mt-2 max-w-lg"> 43 47 {profile.description} 44 - </p> 48 + </Text> 45 49 )} 46 50 </div> 47 51 )} 48 52 49 - <div className="flex justify-between items-center mb-8"> 50 - <h1 className="text-3xl font-bold text-zinc-900">Slices</h1> 53 + <div className="flex justify-end items-center mb-8"> 51 54 {isOwnProfile && ( 52 55 <Button 53 56 type="button" 54 - variant="primary" 57 + variant="success" 55 58 hx-get="/dialogs/create-slice" 56 59 hx-target="body" 57 60 hx-swap="beforeend" 58 61 > 59 - + Create Slice 62 + Create Slice 60 63 </Button> 61 64 )} 62 65 </div> 63 66 64 - {slices.length > 0 65 - ? ( 66 - <div className="space-y-4"> 67 - <div className="flex items-center justify-between mb-4"> 68 - <p className="text-sm text-zinc-500"> 69 - Activity shows records indexed in the past 24 hours 70 - </p> 71 - </div> 72 - {slices.map((slice) => ( 73 - <SliceCard 74 - key={slice.uri} 75 - slice={slice} 76 - /> 77 - ))} 78 - </div> 79 - ) 80 - : ( 81 - <div className="bg-white border border-zinc-200"> 82 - <EmptyState 83 - icon={<FileText size={64} strokeWidth={1} />} 84 - title="No slices yet" 85 - description={isOwnProfile 67 + {slices.length > 0 ? ( 68 + <div className="space-y-4"> 69 + {slices.map((slice) => ( 70 + <SliceCard key={slice.uri} slice={slice} /> 71 + ))} 72 + </div> 73 + ) : ( 74 + <Card className="p-0"> 75 + <EmptyState 76 + icon={<FileText size={64} strokeWidth={1} />} 77 + title="No slices yet" 78 + description={ 79 + isOwnProfile 86 80 ? "Create your first slice to get started organizing your AT Protocol data." 87 - : "This user hasn't created any slices yet."} 88 - > 89 - {isOwnProfile && ( 90 - <Button 91 - type="button" 92 - variant="primary" 93 - hx-get="/dialogs/create-slice" 94 - hx-target="body" 95 - hx-swap="beforeend" 96 - > 97 - Create Your First Slice 98 - </Button> 99 - )} 100 - </EmptyState> 101 - </div> 102 - )} 81 + : "This user hasn't created any slices yet." 82 + } 83 + > 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 + </Card> 97 + )} 103 98 </div> 104 99 </Layout> 105 100 );
+51 -76
frontend/src/features/dashboard/templates/fragments/CreateSliceDialog.tsx
··· 1 1 import { Input } from "../../../../shared/fragments/Input.tsx"; 2 2 import { Button } from "../../../../shared/fragments/Button.tsx"; 3 + import { Text } from "../../../../shared/fragments/Text.tsx"; 4 + import { Modal } from "../../../../shared/fragments/Modal.tsx"; 3 5 4 6 interface CreateSliceDialogProps { 5 7 error?: string; ··· 13 15 domain = "", 14 16 }: CreateSliceDialogProps) { 15 17 return ( 16 - <div 17 - id="create-slice-modal" 18 - className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50" 19 - hx-on:click="if (event.target === this) this.remove()" 20 - > 21 - <div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"> 22 - <div className="mt-3"> 23 - <div className="flex justify-between items-center mb-4"> 24 - <h3 className="text-lg font-medium text-gray-900"> 25 - Create New Slice 26 - </h3> 27 - <button 28 - type="button" 29 - className="text-gray-400 hover:text-gray-600" 30 - _="on click remove #create-slice-modal" 31 - > 32 - <svg 33 - className="h-6 w-6" 34 - fill="none" 35 - viewBox="0 0 24 24" 36 - stroke="currentColor" 37 - > 38 - <path 39 - strokeLinecap="round" 40 - strokeLinejoin="round" 41 - strokeWidth={2} 42 - d="M6 18L18 6M6 6l12 12" 43 - /> 44 - </svg> 45 - </button> 18 + <div id="create-slice-modal"> 19 + <Modal 20 + title="Create New Slice" 21 + size="sm" 22 + onClose="on click remove #create-slice-modal" 23 + > 24 + {error && ( 25 + <div className="mb-4 p-3 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-800 text-red-700 dark:text-red-300 rounded"> 26 + {error} 46 27 </div> 28 + )} 47 29 48 - {error && ( 49 - <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded"> 50 - {error} 51 - </div> 52 - )} 30 + <form 31 + hx-post="/slices" 32 + hx-target="#create-slice-modal" 33 + hx-swap="outerHTML" 34 + className="space-y-4" 35 + > 36 + <Input 37 + type="text" 38 + id="name" 39 + name="name" 40 + label="Slice Name" 41 + required 42 + defaultValue={name} 43 + placeholder="Enter slice name" 44 + /> 53 45 54 - <form 55 - hx-post="/slices" 56 - hx-target="#create-slice-modal" 57 - hx-swap="outerHTML" 58 - className="space-y-4" 59 - > 46 + <div> 60 47 <Input 61 48 type="text" 62 - id="name" 63 - name="name" 64 - label="Slice Name" 49 + id="domain" 50 + name="domain" 51 + label="Primary Domain" 65 52 required 66 - defaultValue={name} 67 - placeholder="Enter slice name" 53 + defaultValue={domain} 54 + placeholder="e.g. social.grain" 68 55 /> 56 + <Text as="p" size="xs" variant="muted" className="mt-1"> 57 + Primary namespace for this slice's collections 58 + </Text> 59 + </div> 69 60 70 - <div> 71 - <Input 72 - type="text" 73 - id="domain" 74 - name="domain" 75 - label="Primary Domain" 76 - required 77 - defaultValue={domain} 78 - placeholder="e.g. social.grain" 79 - /> 80 - <p className="mt-1 text-xs text-gray-500"> 81 - Primary namespace for this slice's collections 82 - </p> 83 - </div> 84 - 85 - <div className="flex justify-end space-x-3 pt-4"> 86 - <Button 87 - type="button" 88 - variant="secondary" 89 - _="on click remove #create-slice-modal" 90 - > 91 - Cancel 92 - </Button> 93 - <Button type="submit" variant="primary"> 94 - Create Slice 95 - </Button> 96 - </div> 97 - </form> 98 - </div> 99 - </div> 61 + <div className="flex justify-end space-x-3 pt-4"> 62 + <Button 63 + type="button" 64 + variant="secondary" 65 + _="on click remove #create-slice-modal" 66 + > 67 + Cancel 68 + </Button> 69 + <Button type="submit" variant="success"> 70 + Create Slice 71 + </Button> 72 + </div> 73 + </form> 74 + </Modal> 100 75 </div> 101 76 ); 102 77 }
+19 -16
frontend/src/features/docs/handlers.tsx
··· 58 58 try { 59 59 const highlightedCode = await codeToHtml(code.trim(), { 60 60 lang: lang || "text", 61 - theme: "tokyo-night", 61 + themes: { 62 + light: "github-light", 63 + dark: "github-dark", 64 + }, 62 65 }); 63 66 64 67 // Wrap in a container with proper styling ··· 73 76 // Fallback to simple code block if Shiki fails 74 77 console.warn("Shiki highlighting failed:", error); 75 78 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>`; 79 + `<pre class="bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md p-4 overflow-x-auto my-4"><code class="text-sm text-zinc-900 dark:text-zinc-100">${code.trim()}</code></pre>`; 77 80 codeBlocks.push({ 78 81 placeholder, 79 82 replacement: fallback, ··· 90 93 // Headers with inline code (process these first to handle backticks in headers) 91 94 .replace( 92 95 /^#### `([^`]+)`$/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>', 96 + '<h4 class="text-base font-semibold text-zinc-900 dark:text-white mt-6 mb-3"><code class="bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-2 py-1 rounded text-sm font-mono font-normal">$1</code></h4>', 94 97 ) 95 98 .replace( 96 99 /^### `([^`]+)`$/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>', 100 + '<h3 class="text-lg font-semibold text-zinc-900 dark:text-white mt-8 mb-4"><code class="bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-2 py-1 rounded font-mono font-normal">$1</code></h3>', 98 101 ) 99 102 .replace( 100 103 /^## `([^`]+)`$/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>', 104 + '<h2 class="text-xl font-bold text-zinc-900 dark:text-white mt-10 mb-4"><code class="bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-2 py-1 rounded font-mono font-normal">$1</code></h2>', 102 105 ) 103 106 // Regular headers (without backticks) 104 107 .replace( 105 108 /^#### (.*$)/gm, 106 - '<h4 class="text-base font-semibold text-zinc-900 mt-6 mb-3">$1</h4>', 109 + '<h4 class="text-base font-semibold text-zinc-900 dark:text-white mt-6 mb-3">$1</h4>', 107 110 ) 108 111 .replace( 109 112 /^### (.*$)/gm, 110 - '<h3 class="text-lg font-semibold text-zinc-900 mt-8 mb-4">$1</h3>', 113 + '<h3 class="text-lg font-semibold text-zinc-900 dark:text-white mt-8 mb-4">$1</h3>', 111 114 ) 112 115 .replace( 113 116 /^## (.*$)/gm, 114 - '<h2 class="text-xl font-bold text-zinc-900 mt-10 mb-4">$1</h2>', 117 + '<h2 class="text-xl font-bold text-zinc-900 dark:text-white mt-10 mb-4">$1</h2>', 115 118 ) 116 119 .replace( 117 120 /^# (.*$)/gm, 118 - '<h1 class="text-2xl font-bold text-zinc-900 mt-10 mb-6">$1</h1>', 121 + '<h1 class="text-2xl font-bold text-zinc-900 dark:text-white mt-10 mb-6">$1</h1>', 119 122 ) 120 123 // Inline code (for non-header text) 121 124 .replace( 122 125 /`([^`]+)`/g, 123 - '<code class="bg-zinc-100 px-1.5 py-0.5 rounded text-sm font-mono font-normal">$1</code>', 126 + '<code class="bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-1.5 py-0.5 rounded text-sm font-mono font-normal">$1</code>', 124 127 ) 125 128 // Bold 126 - .replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>') 129 + .replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-zinc-900 dark:text-white">$1</strong>') 127 130 // Lists (handle both - and * syntax, process before italic to avoid conflicts) 128 131 .replace( 129 132 /^[\-\*] (.*$)/gm, ··· 138 141 // Convert relative .md links to docs routes 139 142 if (url.endsWith(".md") && !url.startsWith("http")) { 140 143 const slug = url.replace(/^\.\//, "").replace(/\.md$/, ""); 141 - return `<a href="/docs/${slug}" class="text-blue-600 hover:text-blue-800 underline">${text}</a>`; 144 + return `<a href="/docs/${slug}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline">${text}</a>`; 142 145 } 143 - return `<a href="${url}" class="text-blue-600 hover:text-blue-800 underline">${text}</a>`; 146 + return `<a href="${url}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline">${text}</a>`; 144 147 }); 145 148 146 149 // Group consecutive list items into ul/ol elements ··· 148 151 /(<li[^>]*data-type="unordered"[^>]*>.*?<\/li>\s*)+/gs, 149 152 (match) => { 150 153 const cleanedMatch = match.replace(/data-type="unordered"/g, ""); 151 - return `<ul class="list-disc list-inside my-4">${cleanedMatch}</ul>`; 154 + return `<ul class="list-disc list-inside my-4 text-zinc-700 dark:text-zinc-300">${cleanedMatch}</ul>`; 152 155 }, 153 156 ); 154 157 ··· 156 159 /(<li[^>]*data-type="ordered"[^>]*>.*?<\/li>\s*)+/gs, 157 160 (match) => { 158 161 const cleanedMatch = match.replace(/data-type="ordered"/g, ""); 159 - return `<ol class="list-decimal list-inside my-4">${cleanedMatch}</ol>`; 162 + return `<ol class="list-decimal list-inside my-4 text-zinc-700 dark:text-zinc-300">${cleanedMatch}</ol>`; 160 163 }, 161 164 ); 162 165 ··· 168 171 if (trimmed.startsWith("<") || trimmed.startsWith("__CODE_BLOCK_")) { 169 172 return trimmed; // Already HTML or placeholder 170 173 } 171 - return `<p class="mb-4 leading-relaxed">${trimmed}</p>`; 174 + return `<p class="mb-4 leading-relaxed text-zinc-700 dark:text-zinc-300">${trimmed}</p>`; 172 175 }) 173 176 .join("\n"); 174 177
+16 -11
frontend/src/features/docs/templates/DocsIndexPage.tsx
··· 1 1 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 2 2 import { Layout } from "../../../shared/fragments/Layout.tsx"; 3 + import { Card } from "../../../shared/fragments/Card.tsx"; 4 + import { Text } from "../../../shared/fragments/Text.tsx"; 5 + import { Link } from "../../../shared/fragments/Link.tsx"; 3 6 4 7 interface DocItem { 5 8 slug: string; ··· 17 20 <Layout title="Documentation - Slices" currentUser={currentUser}> 18 21 <div className="py-8 px-4"> 19 22 <div className="mb-8"> 20 - <h1 className="text-3xl font-bold text-zinc-900 mb-2"> 23 + <Text as="h1" size="3xl" className="font-bold mb-2"> 21 24 Documentation 22 - </h1> 23 - <p className="text-zinc-600"> 25 + </Text> 26 + <Text as="p" variant="secondary"> 24 27 Learn how to build AT Protocol applications with Slices 25 - </p> 28 + </Text> 26 29 </div> 27 30 28 31 <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> ··· 30 33 <a 31 34 key={doc.slug} 32 35 href={`/docs/${doc.slug}`} 33 - className="block p-6 bg-white border border-zinc-200 rounded-lg hover:border-zinc-300 hover:shadow-sm transition-all" 36 + className="block" 34 37 > 35 - <h2 className="text-xl font-semibold text-zinc-900 mb-2"> 36 - {doc.title} 37 - </h2> 38 - <p className="text-zinc-600"> 39 - {doc.description} 40 - </p> 38 + <Card variant="hover"> 39 + <Text as="h2" size="xl" className="font-semibold mb-2"> 40 + {doc.title} 41 + </Text> 42 + <Text as="p" variant="secondary"> 43 + {doc.description} 44 + </Text> 45 + </Card> 41 46 </a> 42 47 ))} 43 48 </div>
+9 -8
frontend/src/features/docs/templates/DocsPage.tsx
··· 1 1 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 2 2 import { Layout } from "../../../shared/fragments/Layout.tsx"; 3 + import { Text } from "../../../shared/fragments/Text.tsx"; 3 4 4 5 interface DocItem { 5 6 slug: string; ··· 25 26 <div className="sm:hidden mb-6"> 26 27 <label 27 28 htmlFor="docs-nav" 28 - className="block text-sm font-medium text-zinc-700 mb-2" 29 + className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2" 29 30 > 30 31 Navigate to 31 32 </label> 32 33 <select 33 34 id="docs-nav" 34 - className="block w-full px-3 py-2 text-base border border-zinc-300 bg-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500" 35 + className="block w-full px-3 py-2 text-base border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent" 35 36 value={currentSlug} 36 37 _="on change set window.location to `/docs/${me.value}`" 37 38 > ··· 51 52 {/* Desktop Sidebar */} 52 53 <nav className="hidden sm:block w-64 flex-shrink-0"> 53 54 <div className="sticky sm:top-[5rem]"> 54 - <h2 className="text-sm font-semibold text-zinc-900 mb-4"> 55 + <Text as="h2" size="sm" className="font-semibold mb-4"> 55 56 Documentation 56 - </h2> 57 + </Text> 57 58 <ul className="space-y-1"> 58 59 {docs.map((doc) => ( 59 60 <li key={doc.slug}> ··· 61 62 href={`/docs/${doc.slug}`} 62 63 className={`block px-3 py-2 text-sm rounded-md transition-colors ${ 63 64 doc.slug === currentSlug 64 - ? "bg-zinc-100 text-zinc-900 font-medium" 65 - : "text-zinc-600 hover:text-zinc-900 hover:bg-zinc-50" 65 + ? "bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white font-medium" 66 + : "text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800" 66 67 }`} 67 68 > 68 69 {doc.title} ··· 75 76 76 77 {/* Content */} 77 78 <main className="flex-1 min-w-0 overflow-x-hidden"> 78 - <article className="prose prose-zinc max-w-none prose-sm sm:prose-base"> 79 + <article className="prose prose-zinc dark:prose-invert max-w-none prose-sm sm:prose-base"> 79 80 <div 80 - className="docs-content [&_pre]:overflow-x-auto [&_pre]:max-w-full" 81 + className="docs-content [&_pre]:overflow-x-auto [&_pre]:max-w-full [&_pre]:border [&_pre]:border-zinc-200 dark:[&_pre]:border-zinc-700 [&_pre]:rounded-md [&_pre>code]:border-0 [&_:not(pre)>code]:border [&_:not(pre)>code]:border-zinc-200 dark:[&_:not(pre)>code]:border-zinc-700 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:px-1" 81 82 dangerouslySetInnerHTML={{ __html: content }} 82 83 /> 83 84 </article>
+20 -32
frontend/src/features/landing/templates/LandingPage.tsx
··· 1 1 import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 + import { PageHeader } from "../../../shared/fragments/PageHeader.tsx"; 2 3 import { SliceCard } from "../../../shared/fragments/SliceCard.tsx"; 3 4 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 4 5 import type { NetworkSlicesSliceDefsSliceView } from "../../../client.ts"; ··· 19 20 currentUser={currentUser} 20 21 > 21 22 <div className="px-4 py-8"> 22 - <div className="mb-8"> 23 - <h1 className="text-3xl font-bold text-zinc-900">Timeline</h1> 24 - </div> 23 + <PageHeader title="Timeline" /> 25 24 26 - {slices.length > 0 27 - ? ( 28 - <div className="space-y-4"> 29 - <div className="flex items-center justify-between mb-4"> 30 - <p className="text-sm text-zinc-500"> 31 - Activity shows records indexed in the past 24 hours 32 - </p> 33 - </div> 34 - {slices.map((slice) => ( 35 - <SliceCard 36 - key={slice.uri} 37 - slice={slice} 38 - /> 39 - ))} 40 - </div> 41 - ) 42 - : ( 43 - <div className="flex justify-center items-center min-h-[60vh]"> 44 - <div className="text-center"> 45 - <p className="text-zinc-600 mb-6"> 46 - No slices yet. Create your first slice to get started! 25 + {slices.length > 0 ? ( 26 + <div className="space-y-4"> 27 + {slices.map((slice) => ( 28 + <SliceCard key={slice.uri} slice={slice} /> 29 + ))} 30 + </div> 31 + ) : ( 32 + <div className="flex justify-center items-center min-h-[60vh]"> 33 + <div className="text-center"> 34 + <p className="text-zinc-600 mb-6"> 35 + No slices yet. Create your first slice to get started! 36 + </p> 37 + {!currentUser?.isAuthenticated && ( 38 + <p className="text-zinc-500 text-sm"> 39 + Join the waitlist for early access to Slices 47 40 </p> 48 - {!currentUser?.isAuthenticated && ( 49 - <p className="text-zinc-500 text-sm"> 50 - Join the waitlist for early access to Slices 51 - </p> 52 - )} 53 - </div> 41 + )} 54 42 </div> 55 - )} 56 - 43 + </div> 44 + )} 57 45 </div> 58 46 </Layout> 59 47 );
+4 -3
frontend/src/features/settings/templates/SettingsPage.tsx
··· 1 1 import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 2 import { FlashMessage } from "../../../shared/fragments/FlashMessage.tsx"; 3 3 import { SettingsForm } from "./fragments/SettingsForm.tsx"; 4 + import { Text } from "../../../shared/fragments/Text.tsx"; 4 5 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 5 6 6 7 interface SettingsPageProps { ··· 24 25 <Layout title="Settings - Slice" currentUser={currentUser}> 25 26 <div className="px-4 py-8"> 26 27 <div className="mb-8"> 27 - <h1 className="text-3xl font-bold text-zinc-900">Settings</h1> 28 - <p className="mt-2 text-zinc-600"> 28 + <Text as="h1" size="3xl" className="font-bold">Settings</Text> 29 + <Text as="p" variant="secondary" className="mt-2"> 29 30 Manage your profile information and preferences. 30 - </p> 31 + </Text> 31 32 </div> 32 33 33 34 {/* Flash Messages */}
+6 -4
frontend/src/features/settings/templates/fragments/SettingsForm.tsx
··· 2 2 import { Textarea } from "../../../../shared/fragments/Textarea.tsx"; 3 3 import { Button } from "../../../../shared/fragments/Button.tsx"; 4 4 import { AvatarInput } from "../../../../shared/fragments/AvatarInput.tsx"; 5 + import { Card } from "../../../../shared/fragments/Card.tsx"; 6 + import { Text } from "../../../../shared/fragments/Text.tsx"; 5 7 6 8 interface SettingsFormProps { 7 9 profile?: { ··· 14 16 15 17 export function SettingsForm({ profile }: SettingsFormProps) { 16 18 return ( 17 - <div className="bg-white border border-zinc-200 p-6"> 18 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 19 + <Card> 20 + <Text as="h2" size="xl" className="font-semibold mb-4"> 19 21 Profile Settings 20 - </h2> 22 + </Text> 21 23 22 24 <form 23 25 hx-put="/api/profile" ··· 63 65 <div id="settings-result" className="mt-4"> 64 66 {/* Results will be loaded here via htmx */} 65 67 </div> 66 - </div> 68 + </Card> 67 69 ); 68 70 }
+6 -4
frontend/src/features/settings/templates/fragments/SettingsResult.tsx
··· 1 + import { Text } from "../../../../shared/fragments/Text.tsx"; 2 + 1 3 interface SettingsResultProps { 2 4 type: "success" | "error"; 3 5 message: string; ··· 10 12 showRefresh, 11 13 }: SettingsResultProps) { 12 14 const bgColor = type === "success" 13 - ? "bg-green-50 border-green-200 text-green-700" 14 - : "bg-red-50 border-red-200 text-red-700"; 15 + ? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300" 16 + : "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300"; 15 17 const icon = type === "success" ? "✅" : "❌"; 16 18 17 19 return ( 18 20 <div className={`border px-4 py-3 ${bgColor}`}> 19 21 <div className="flex items-center"> 20 22 <span className="mr-2">{icon}</span> 21 - <span>{message}</span> 23 + <Text as="span">{message}</Text> 22 24 </div> 23 25 {showRefresh && ( 24 26 <button 25 27 type="button" 26 - className="mt-2 text-sm underline" 28 + className="mt-2 text-sm underline hover:no-underline" 27 29 _="on click call window.location.reload()" 28 30 > 29 31 Refresh page to see changes
+24 -14
frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx
··· 31 31 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 32 32 <title>API Docs - {sliceName}</title> 33 33 <script src="https://cdn.tailwindcss.com"></script> 34 + <script 35 + dangerouslySetInnerHTML={{ 36 + __html: ` 37 + // Set dark mode based on system preference 38 + if (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches) { 39 + document.documentElement.classList.add('dark') 40 + } 41 + `, 42 + }} 43 + /> 34 44 </head> 35 - <body class="bg-gray-50 min-h-screen"> 45 + <body class="bg-zinc-50 dark:bg-zinc-900 min-h-screen"> 36 46 {/* Header with back button */} 37 - <div class="bg-white border-b border-gray-200 px-4 py-4"> 47 + <div class="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-700 px-4 py-4"> 38 48 <div class="max-w-7xl mx-auto flex items-center justify-between"> 39 49 <div class="flex items-center"> 40 50 <a 41 51 href={buildSliceUrlFromView(slice, sliceId)} 42 - class="text-blue-600 hover:text-blue-800 mr-4 flex items-center" 52 + class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 mr-4 flex items-center" 43 53 > 44 54 <svg 45 55 class="w-4 h-4 mr-1" ··· 58 68 </a> 59 69 </div> 60 70 <div class="text-right"> 61 - <h1 class="text-xl font-semibold text-gray-900"> 71 + <h1 class="text-xl font-semibold text-zinc-900 dark:text-white"> 62 72 API Documentation 63 73 </h1> 64 - <p class="text-gray-600 text-sm"> 74 + <p class="text-zinc-600 dark:text-zinc-400 text-sm"> 65 75 Interactive OpenAPI docs for your slice 66 76 </p> 67 77 </div> ··· 69 79 </div> 70 80 71 81 {/* Info bar */} 72 - <div class="bg-blue-50 border-b border-blue-200 px-4 py-3"> 82 + <div class="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800 px-4 py-3"> 73 83 <div class="max-w-7xl mx-auto"> 74 - <p class="text-blue-800 text-sm"> 84 + <p class="text-blue-800 dark:text-blue-300 text-sm"> 75 85 <strong>OpenAPI Spec URL:</strong> 76 - <code class="ml-2 bg-blue-100 px-2 py-1 rounded text-xs"> 86 + <code class="ml-2 bg-blue-100 dark:bg-blue-900/50 px-2 py-1 rounded text-xs"> 77 87 {openApiUrl} 78 88 </code> 79 89 </p> ··· 85 95 <div id="scalar-api-reference" class="w-full min-h-screen"> 86 96 <div class="flex items-center justify-center h-96"> 87 97 <div class="text-center"> 88 - <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"> 98 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 dark:border-blue-400 mx-auto mb-4"> 89 99 </div> 90 - <p class="text-gray-500">Loading API documentation...</p> 100 + <p class="text-zinc-500 dark:text-zinc-400">Loading API documentation...</p> 91 101 </div> 92 102 </div> 93 103 </div> ··· 160 170 document.getElementById('scalar-api-reference').innerHTML = \` 161 171 <div class="flex items-center justify-center h-64 text-center"> 162 172 <div> 163 - <div class="text-red-500 mb-4"> 173 + <div class="text-red-500 dark:text-red-400 mb-4"> 164 174 <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 165 175 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 166 176 d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z"/> 167 177 </svg> 168 178 </div> 169 - <h3 class="text-lg font-medium text-gray-900 mb-2">Failed to load API documentation</h3> 170 - <p class="text-gray-600 text-sm mb-4"> 179 + <h3 class="text-lg font-medium text-zinc-900 dark:text-white mb-2">Failed to load API documentation</h3> 180 + <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-4"> 171 181 Unable to fetch the OpenAPI specification. Please make sure the API server is running. 172 182 </p> 173 183 <button 174 184 onclick="window.location.reload()" 175 - class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" 185 + class="bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium" 176 186 > 177 187 Retry 178 188 </button>
+25 -80
frontend/src/features/slices/codegen/handlers.tsx
··· 5 5 import { withSliceAccess } from "../../../routes/slice-middleware.ts"; 6 6 import { extractSliceParams } from "../../../utils/slice-params.ts"; 7 7 import { SliceCodegenPage } from "./templates/SliceCodegenPage.tsx"; 8 - import { CodegenResult } from "./templates/fragments/CodegenResult.tsx"; 9 8 10 9 async function handleSliceCodegenPage( 11 10 req: Request, ··· 29 28 return new Response("Slice not found", { status: 404 }); 30 29 } 31 30 32 - return renderHTML( 33 - <SliceCodegenPage 34 - slice={context.sliceContext!.slice!} 35 - sliceId={sliceParams.sliceId} 36 - currentUser={authContext.currentUser} 37 - hasSliceAccess={context.sliceContext?.hasAccess} 38 - />, 39 - ); 40 - } 41 - 42 - async function handleSliceCodegen( 43 - req: Request, 44 - params?: URLPatternResult, 45 - ): Promise<Response> { 46 - const authContext = await withAuth(req); 47 - 48 - const sliceId = params?.pathname.groups.id; 49 - if (!sliceId) { 50 - const component = await CodegenResult({ 51 - success: false, 52 - error: "Invalid slice ID", 53 - }); 54 - return renderHTML(component, { status: 400 }); 55 - } 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 - } 31 + // Automatically generate the TypeScript client 32 + let generatedCode: string | undefined; 33 + let error: string | undefined; 83 34 84 35 try { 85 - // Parse form data 86 - const target = formData.get("format") || "typescript"; 87 - 88 - // Use the slice-specific client with owner DID 89 - const sliceClient = getSliceClient(authContext, sliceId, context.sliceContext.profileDid); 90 - 91 - // Call the codegen XRPC endpoint 36 + const sliceClient = getSliceClient(authContext, sliceParams.sliceId, context.sliceContext.profileDid); 92 37 const result = await sliceClient.network.slices.slice.codegen({ 93 - target: target as string, 38 + target: "typescript", 94 39 slice: context.sliceContext!.sliceUri, 95 40 }); 96 41 97 - const component = await CodegenResult({ 98 - success: result.success, 99 - generatedCode: result.generatedCode, 100 - error: result.error, 101 - }); 102 - 103 - return renderHTML(component); 104 - } catch (error) { 105 - console.error("Codegen error:", error); 106 - const component = await CodegenResult({ 107 - success: false, 108 - error: `Error: ${error instanceof Error ? error.message : String(error)}`, 109 - }); 110 - 111 - return renderHTML(component); 42 + if (result.success) { 43 + generatedCode = result.generatedCode; 44 + } else { 45 + error = result.error; 46 + } 47 + } catch (e) { 48 + console.error("Codegen error:", e); 49 + error = `Error generating client: ${e instanceof Error ? e.message : String(e)}`; 112 50 } 51 + 52 + return renderHTML( 53 + await SliceCodegenPage({ 54 + slice: context.sliceContext!.slice!, 55 + sliceId: sliceParams.sliceId, 56 + currentUser: authContext.currentUser, 57 + hasSliceAccess: context.sliceContext?.hasAccess, 58 + generatedCode: generatedCode, 59 + error: error, 60 + }), 61 + ); 113 62 } 114 63 64 + 115 65 export const codegenRoutes: Route[] = [ 116 66 { 117 67 method: "GET", ··· 119 69 pathname: "/profile/:handle/slice/:rkey/codegen", 120 70 }), 121 71 handler: handleSliceCodegenPage, 122 - }, 123 - { 124 - method: "POST", 125 - pattern: new URLPattern({ pathname: "/api/slices/:id/codegen" }), 126 - handler: handleSliceCodegen, 127 72 }, 128 73 ];
+62 -16
frontend/src/features/slices/codegen/templates/SliceCodegenPage.tsx
··· 1 1 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 2 - import { CodegenForm } from "./fragments/CodegenForm.tsx"; 2 + import { Card } from "../../../../shared/fragments/Card.tsx"; 3 + import { Text } from "../../../../shared/fragments/Text.tsx"; 4 + import { Button } from "../../../../shared/fragments/Button.tsx"; 5 + import { codeToHtml } from "jsr:@shikijs/shiki"; 3 6 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 4 7 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 5 8 ··· 8 11 sliceId: string; 9 12 currentUser?: AuthenticatedUser; 10 13 hasSliceAccess?: boolean; 14 + generatedCode?: string; 15 + error?: string; 11 16 } 12 17 13 - export function SliceCodegenPage({ 18 + export async function SliceCodegenPage({ 14 19 slice, 15 20 sliceId, 16 21 currentUser, 17 22 hasSliceAccess, 23 + generatedCode, 24 + error, 18 25 }: SliceCodegenPageProps) { 26 + let highlightedCode: string | undefined; 27 + 28 + if (generatedCode) { 29 + highlightedCode = await codeToHtml(generatedCode, { 30 + lang: "typescript", 31 + themes: { 32 + light: "github-light", 33 + dark: "github-dark", 34 + }, 35 + }); 36 + } 37 + 19 38 return ( 20 39 <SlicePage 21 40 slice={slice} ··· 25 44 hasSliceAccess={hasSliceAccess} 26 45 title={`${slice.name} - Code Generation`} 27 46 > 28 - <CodegenForm sliceId={sliceId} handle={slice.creator.handle} /> 29 - 30 - <div className="bg-white border border-zinc-200"> 31 - <div className="px-6 py-4 border-b border-zinc-200"> 32 - <h2 className="text-lg font-semibold text-zinc-900"> 33 - Generated Client 34 - </h2> 35 - </div> 36 - <div id="codegen-results"> 37 - <div className="p-6 text-center text-zinc-500"> 38 - Generate a client to see the results here. 39 - </div> 40 - </div> 41 - </div> 47 + <Card padding="none"> 48 + <Card.Header 49 + title="TypeScript Client" 50 + action={ 51 + generatedCode ? ( 52 + <Button 53 + variant="success" 54 + size="md" 55 + _={`on click js navigator.clipboard.writeText(${JSON.stringify( 56 + generatedCode 57 + )}).then(() => { me.textContent = 'Copied!'; setTimeout(() => me.textContent = 'Copy to Clipboard', 2000); }) end`} 58 + > 59 + Copy to Clipboard 60 + </Button> 61 + ) : undefined 62 + } 63 + /> 64 + <Card.Content> 65 + {error ? ( 66 + <div className="p-6"> 67 + <Card variant="danger"> 68 + <Text as="h3" size="lg" className="font-semibold mb-2"> 69 + ❌ Generation Failed 70 + </Text> 71 + <Text variant="error">{error}</Text> 72 + </Card> 73 + </div> 74 + ) : highlightedCode ? ( 75 + <div className="bg-zinc-50 dark:bg-zinc-900"> 76 + <div 77 + className="text-sm overflow-x-auto [&_pre]:p-4 [&_pre]:m-0 [&_pre]:min-w-full [&_pre]:w-max" 78 + dangerouslySetInnerHTML={{ __html: highlightedCode }} 79 + /> 80 + </div> 81 + ) : ( 82 + <div className="p-6 text-center"> 83 + <Text variant="muted">Loading TypeScript client...</Text> 84 + </div> 85 + )} 86 + </Card.Content> 87 + </Card> 42 88 </SlicePage> 43 89 ); 44 90 }
-61
frontend/src/features/slices/codegen/templates/fragments/CodegenForm.tsx
··· 1 - import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 - import { Select } from "../../../../../shared/fragments/Select.tsx"; 3 - 4 - interface CodegenFormProps { 5 - sliceId: string; 6 - handle: string; 7 - } 8 - 9 - export function CodegenForm({ sliceId, handle }: CodegenFormProps) { 10 - return ( 11 - <div className="bg-white border border-zinc-200 p-6 mb-6"> 12 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 13 - Generate ATProtocol Client 14 - </h2> 15 - <p className="text-zinc-600 mb-6"> 16 - Generate an ATProtocol client library from the lexicon definitions in 17 - this slice. 18 - </p> 19 - 20 - <form 21 - hx-post={`/api/slices/${sliceId}/codegen`} 22 - hx-target="#codegen-results" 23 - hx-swap="innerHTML" 24 - hx-on="htmx:afterRequest: if(event.detail.successful) document.getElementById('copy-button').style.display = 'block'" 25 - className="space-y-4" 26 - > 27 - <input type="hidden" name="handle" value={handle} /> 28 - <Select label="Output Format" name="format"> 29 - <option value="typescript">TypeScript</option> 30 - </Select> 31 - 32 - <div className="flex gap-2"> 33 - <Button 34 - type="submit" 35 - variant="warning" 36 - class="flex items-center justify-center" 37 - > 38 - <i 39 - data-lucide="loader-2" 40 - className="htmx-indicator animate-spin mr-2 h-4 w-4" 41 - _="on load js lucide.createIcons() end" 42 - > 43 - </i> 44 - <span className="htmx-indicator">Generating Client...</span> 45 - <span className="default-text">Generate Client</span> 46 - </Button> 47 - 48 - <Button 49 - type="button" 50 - variant="success" 51 - style="display: none;" 52 - id="copy-button" 53 - _="on click js(me) navigator.clipboard.writeText(document.querySelector('#codegen-results pre')?.textContent || '').then(() => { me.textContent = 'Copied!'; setTimeout(() => me.textContent = 'Copy to Clipboard', 2000); }) end" 54 - > 55 - Copy to Clipboard 56 - </Button> 57 - </div> 58 - </form> 59 - </div> 60 - ); 61 - }
-36
frontend/src/features/slices/codegen/templates/fragments/CodegenResult.tsx
··· 1 - import { codeToHtml } from "jsr:@shikijs/shiki"; 2 - 3 - interface CodegenResultProps { 4 - success: boolean; 5 - generatedCode?: string; 6 - error?: string; 7 - } 8 - 9 - export async function CodegenResult({ 10 - success, 11 - generatedCode, 12 - error, 13 - }: CodegenResultProps) { 14 - if (success && generatedCode) { 15 - const highlightedCode = await codeToHtml(generatedCode, { 16 - lang: "typescript", 17 - theme: "tokyo-night", 18 - }); 19 - 20 - return ( 21 - <div 22 - className="text-sm overflow-x-auto [&_pre]:p-4" 23 - dangerouslySetInnerHTML={{ __html: highlightedCode }} 24 - /> 25 - ); 26 - } else { 27 - return ( 28 - <div className="bg-red-50 border border-red-200 p-4"> 29 - <h3 className="text-lg font-semibold text-red-800 mb-2"> 30 - ❌ Generation Failed 31 - </h3> 32 - <p className="text-red-700">{error || "Unknown error occurred"}</p> 33 - </div> 34 - ); 35 - } 36 - }
+22 -37
frontend/src/features/slices/jetstream/handlers.tsx
··· 12 12 import { JetstreamLogsPage } from "./templates/JetstreamLogsPage.tsx"; 13 13 import { JetstreamLogs } from "./templates/fragments/JetstreamLogs.tsx"; 14 14 import { JetstreamStatus } from "./templates/fragments/JetstreamStatus.tsx"; 15 + import { JetstreamStatusDisplay } from "./templates/fragments/JetstreamStatusDisplay.tsx"; 15 16 import { buildSliceUrl } from "../../../utils/slice-params.ts"; 16 17 import type { LogEntry } from "../../../client.ts"; 17 18 18 19 async function handleJetstreamLogs( 19 20 req: Request, 20 - params?: URLPatternResult, 21 + params?: URLPatternResult 21 22 ): Promise<Response> { 22 23 const context = await withAuth(req); 23 24 const authResponse = requireAuth(context); ··· 27 28 if (!sliceId) { 28 29 return renderHTML( 29 30 <div className="p-8 text-center text-red-600">❌ Invalid slice ID</div>, 30 - { status: 400 }, 31 + { status: 400 } 31 32 ); 32 33 } 33 34 ··· 45 46 // Sort logs in descending order (newest first) 46 47 const sortedLogs = logs.sort( 47 48 (a, b) => 48 - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 49 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 49 50 ); 50 51 51 52 // Render the log content ··· 72 73 </div> 73 74 </div> 74 75 </Layout>, 75 - { status: 500 }, 76 + { status: 500 } 76 77 ); 77 78 } 78 79 } 79 80 80 81 async function handleJetstreamStatus( 81 82 req: Request, 82 - _params?: URLPatternResult, 83 + _params?: URLPatternResult 83 84 ): Promise<Response> { 84 85 try { 85 86 // Extract parameters from query ··· 94 95 // Render compact version for logs page 95 96 if (isCompact) { 96 97 return renderHTML( 97 - <div className="inline-flex items-center gap-2 text-xs"> 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>, 98 + <JetstreamStatusDisplay connected={data.connected} isCompact={true} /> 113 99 ); 114 100 } 115 101 116 102 // Generate jetstream URL if we have both handle and sliceId 117 - const jetstreamUrl = handle && sliceId 118 - ? buildSliceUrl(handle, sliceId, "jetstream") 119 - : undefined; 103 + const jetstreamUrl = 104 + handle && sliceId 105 + ? buildSliceUrl(handle, sliceId, "jetstream") 106 + : undefined; 120 107 121 108 // Render full version for main page 122 109 return renderHTML( ··· 125 112 status={data.status} 126 113 error={data.error} 127 114 jetstreamUrl={jetstreamUrl} 128 - />, 115 + /> 129 116 ); 130 117 } catch (error) { 131 118 // Extract parameters for error case too ··· 137 124 // Render compact error version 138 125 if (isCompact) { 139 126 return renderHTML( 140 - <div className="inline-flex items-center gap-2 text-xs"> 141 - <div className="w-2 h-2 bg-red-500 rounded-full"></div> 142 - <span className="text-red-700">Jetstream Offline</span> 143 - </div>, 127 + <JetstreamStatusDisplay connected={false} isCompact={true} /> 144 128 ); 145 129 } 146 130 147 131 // Generate jetstream URL if we have both handle and sliceId 148 - const jetstreamUrl = handle && sliceId 149 - ? buildSliceUrl(handle, sliceId, "jetstream") 150 - : undefined; 132 + const jetstreamUrl = 133 + handle && sliceId 134 + ? buildSliceUrl(handle, sliceId, "jetstream") 135 + : undefined; 151 136 152 137 // Fallback to disconnected state on error for full version 153 138 return renderHTML( ··· 156 141 status="Connection error" 157 142 error={error instanceof Error ? error.message : "Unknown error"} 158 143 jetstreamUrl={jetstreamUrl} 159 - />, 144 + /> 160 145 ); 161 146 } 162 147 } 163 148 164 149 async function handleJetstreamLogsPage( 165 150 req: Request, 166 - params?: URLPatternResult, 151 + params?: URLPatternResult 167 152 ): Promise<Response> { 168 153 const authContext = await withAuth(req); 169 154 const sliceParams = extractSliceParams(params); ··· 175 160 const context = await withSliceAccess( 176 161 authContext, 177 162 sliceParams.handle, 178 - sliceParams.sliceId, 163 + sliceParams.sliceId 179 164 ); 180 165 const accessError = requireSliceAccess(context); 181 166 if (accessError) return accessError; ··· 191 176 }); 192 177 logs = logsResult.logs.sort( 193 178 (a, b) => 194 - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 179 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 195 180 ); 196 181 } catch (error) { 197 182 console.error("Failed to fetch Jetstream logs:", error); ··· 203 188 logs={logs} 204 189 sliceId={sliceParams.sliceId} 205 190 currentUser={authContext.currentUser} 206 - />, 191 + /> 207 192 ); 208 193 } 209 194
+5 -1
frontend/src/features/slices/jetstream/templates/JetstreamLogsPage.tsx
··· 6 6 import { JetstreamLogs } from "./fragments/JetstreamLogs.tsx"; 7 7 import { JetstreamStatusCompact } from "./fragments/JetstreamStatusCompact.tsx"; 8 8 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 9 + import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 9 10 10 11 interface JetstreamLogsPageProps { 11 12 slice: NetworkSlicesSliceDefsSliceView; ··· 26 27 sliceId={sliceId} 27 28 currentUser={currentUser} 28 29 title="Jetstream Logs" 30 + breadcrumbItems={[ 31 + { label: slice.name, href: buildSliceUrlFromView(slice, sliceId) }, 32 + { label: "Jetstream Logs" }, 33 + ]} 29 34 headerActions={<JetstreamStatusCompact sliceId={sliceId} />} 30 35 > 31 36 <div 32 - className="bg-white border border-zinc-200" 33 37 hx-get={`/api/slices/${sliceId}/jetstream/logs`} 34 38 hx-trigger="load, every 20s" 35 39 hx-swap="innerHTML"
+15 -13
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatus.tsx
··· 1 1 import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + import { Card } from "../../../../../shared/fragments/Card.tsx"; 3 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 4 3 5 interface JetstreamStatusProps { 4 6 connected: boolean; ··· 15 17 }: JetstreamStatusProps) { 16 18 if (connected) { 17 19 return ( 18 - <div className="bg-green-50 border border-green-200 p-4 mb-6"> 20 + <Card padding="sm" className="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 mb-6"> 19 21 <div className="flex items-center justify-between"> 20 22 <div className="flex items-center"> 21 23 <div className="w-3 h-3 bg-green-500 rounded-full mr-3 animate-pulse"> 22 24 </div> 23 25 <div> 24 - <h3 className="text-sm font-semibold text-green-800"> 26 + <Text as="h3" size="sm" variant="success" className="font-semibold block"> 25 27 ✈️ Jetstream Connected 26 - </h3> 27 - <p className="text-xs text-green-600"> 28 + </Text> 29 + <Text as="p" size="xs" variant="success"> 28 30 Real-time indexing active - new records are automatically 29 31 indexed 30 - </p> 32 + </Text> 31 33 </div> 32 34 </div> 33 35 <div className="flex items-center gap-3"> ··· 43 45 )} 44 46 </div> 45 47 </div> 46 - </div> 48 + </Card> 47 49 ); 48 50 } else { 49 51 return ( 50 - <div className="bg-red-50 border border-red-200 p-4 mb-6"> 52 + <Card padding="sm" className="bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 mb-6"> 51 53 <div className="flex items-center justify-between"> 52 54 <div className="flex items-center"> 53 55 <div className="w-3 h-3 bg-red-500 rounded-full mr-3"></div> 54 56 <div> 55 - <h3 className="text-sm font-semibold text-red-800"> 57 + <Text as="h3" size="sm" variant="error" className="font-semibold block"> 56 58 🌊 Jetstream Disconnected 57 - </h3> 58 - <p className="text-xs text-red-600"> 59 + </Text> 60 + <Text as="p" size="xs" variant="error"> 59 61 Real-time indexing not active - {status} 60 - </p> 62 + </Text> 61 63 {error && ( 62 - <p className="text-xs text-red-500 mt-1">Error: {error}</p> 64 + <Text as="p" size="xs" variant="error" className="mt-1">Error: {error}</Text> 63 65 )} 64 66 </div> 65 67 </div> ··· 76 78 )} 77 79 </div> 78 80 </div> 79 - </div> 81 + </Card> 80 82 ); 81 83 } 82 84 }
+4 -2
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatusCompact.tsx
··· 1 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 + 1 3 export function JetstreamStatusCompact({ sliceId }: { sliceId: string }) { 2 4 return ( 3 5 <div ··· 6 8 hx-swap="outerHTML" 7 9 className="inline-flex items-center gap-2 text-xs" 8 10 > 9 - <div className="w-2 h-2 bg-zinc-400 rounded-full"></div> 10 - <span className="text-zinc-500">Checking status...</span> 11 + <div className="w-2 h-2 bg-zinc-400 dark:bg-zinc-500 rounded-full"></div> 12 + <Text as="span" variant="muted" size="xs">Checking status...</Text> 11 13 </div> 12 14 ); 13 15 }
+33
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatusDisplay.tsx
··· 1 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 + 3 + interface JetstreamStatusDisplayProps { 4 + connected: boolean; 5 + isCompact?: boolean; 6 + } 7 + 8 + export function JetstreamStatusDisplay({ connected, isCompact = false }: JetstreamStatusDisplayProps) { 9 + if (isCompact) { 10 + return ( 11 + <div className="inline-flex items-center gap-2 text-xs"> 12 + {connected ? ( 13 + <> 14 + <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> 15 + <Text as="span" variant="success" size="xs"> 16 + Jetstream Connected 17 + </Text> 18 + </> 19 + ) : ( 20 + <> 21 + <div className="w-2 h-2 bg-red-500 rounded-full"></div> 22 + <Text as="span" variant="error" size="xs"> 23 + Jetstream Offline 24 + </Text> 25 + </> 26 + )} 27 + </div> 28 + ); 29 + } 30 + 31 + // Full version would be handled by the existing JetstreamStatus component 32 + return null; 33 + }
+72 -59
frontend/src/features/slices/lexicon/handlers.tsx
··· 2 2 import { renderHTML } from "../../../utils/render.tsx"; 3 3 import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 4 4 import { getSliceClient } from "../../../utils/client.ts"; 5 - import { buildAtUri, buildSliceUri } from "../../../utils/at-uri.ts"; 6 - import { atprotoClient } from "../../../config.ts"; 7 - import { 8 - requireSliceAccess, 9 - withSliceAccess, 10 - } from "../../../routes/slice-middleware.ts"; 5 + import { buildSliceUri } from "../../../utils/at-uri.ts"; 6 + import { withSliceAccess } from "../../../routes/slice-middleware.ts"; 11 7 import { extractSliceParams } from "../../../utils/slice-params.ts"; 12 8 import { SliceLexiconPage } from "./templates/SliceLexiconPage.tsx"; 13 9 import { LexiconDetailPage } from "./templates/LexiconDetailPage.tsx"; 14 10 import { EmptyState } from "../../../shared/fragments/EmptyState.tsx"; 15 - import { LexiconSuccessMessage } from "./templates/fragments/LexiconSuccessMessage.tsx"; 16 11 import { LexiconErrorMessage } from "./templates/fragments/LexiconErrorMessage.tsx"; 17 12 import { LexiconsList } from "./templates/fragments/LexiconsList.tsx"; 18 13 import { LexiconFormModal } from "./templates/fragments/LexiconFormModal.tsx"; 19 14 import { FileCode } from "lucide-preact"; 20 15 import { buildSliceUrl } from "../../../utils/slice-params.ts"; 16 + import type { NetworkSlicesLexicon } from "../../../client.ts"; 17 + import type { RecordResponse } from "@slices/client"; 21 18 22 19 async function handleListLexicons( 23 20 req: Request, 24 - params?: URLPatternResult, 21 + params?: URLPatternResult 25 22 ): Promise<Response> { 26 23 const authContext = await withAuth(req); 27 24 ··· 38 35 return new Response("Handle parameter required", { status: 400 }); 39 36 } 40 37 41 - const context = await withSliceAccess( 42 - authContext, 43 - handle, 44 - sliceId, 45 - ); 38 + const context = await withSliceAccess(authContext, handle, sliceId); 46 39 47 40 // Check if slice exists (lexicons list is public) 48 41 if (!context.sliceContext?.slice) { ··· 50 43 } 51 44 52 45 try { 53 - const sliceClient = getSliceClient(authContext, sliceId, context.sliceContext.profileDid); 54 - const lexiconRecords = await sliceClient.network.slices.lexicon 55 - .getRecords(); 46 + const sliceClient = getSliceClient( 47 + authContext, 48 + sliceId, 49 + context.sliceContext.profileDid 50 + ); 51 + const lexiconRecords = 52 + await sliceClient.network.slices.lexicon.getRecords(); 56 53 57 54 if (lexiconRecords.records.length === 0) { 58 55 const isOwner = context.sliceContext?.hasAccess; ··· 60 57 <EmptyState 61 58 icon={<FileCode size={64} strokeWidth={1} />} 62 59 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." 60 + description={ 61 + isOwner 62 + ? "Upload lexicon definitions to define custom schemas for this slice." 63 + : "This slice hasn't defined any lexicon schemas yet." 66 64 } 67 65 withPadding 68 - />, 66 + /> 69 67 ); 70 68 } 71 69 ··· 79 77 handle={handle || undefined} 80 78 sliceDomain={sliceDomain} 81 79 hasSliceAccess={context.sliceContext?.hasAccess} 82 - />, 80 + /> 83 81 ); 84 82 } catch (error) { 85 83 console.error("Failed to fetch lexicons:", error); ··· 87 85 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 88 86 <p>Failed to load lexicons: {error}</p> 89 87 </div>, 90 - { status: 500 }, 88 + { status: 500 } 91 89 ); 92 90 } 93 91 } ··· 103 101 104 102 if (!lexiconJson || lexiconJson.trim().length === 0) { 105 103 return renderHTML( 106 - <LexiconErrorMessage error="Lexicon JSON is required" />, 104 + <LexiconErrorMessage error="Lexicon JSON is required" /> 107 105 ); 108 106 } 109 107 ··· 114 112 return renderHTML( 115 113 <LexiconErrorMessage 116 114 error={`Failed to parse lexicon JSON: ${parseError}`} 117 - />, 115 + /> 118 116 ); 119 117 } 120 118 121 119 if (!lexiconData.id && !lexiconData.nsid) { 122 120 return renderHTML( 123 - <LexiconErrorMessage error="Lexicon must have an 'id' field (e.g., 'com.example.myLexicon')" />, 121 + <LexiconErrorMessage error="Lexicon must have an 'id' field (e.g., 'com.example.myLexicon')" /> 124 122 ); 125 123 } 126 124 127 125 if (!lexiconData.defs && !lexiconData.definitions) { 128 126 return renderHTML( 129 - <LexiconErrorMessage error="Lexicon must have a 'defs' field containing the schema definitions" />, 127 + <LexiconErrorMessage error="Lexicon must have a 'defs' field containing the schema definitions" /> 130 128 ); 131 129 } 132 130 ··· 153 151 }; 154 152 155 153 const sliceClient = getSliceClient(context, sliceId); 156 - await sliceClient.network.slices.lexicon.createRecord( 157 - lexiconRecord, 158 - ); 154 + await sliceClient.network.slices.lexicon.createRecord(lexiconRecord); 159 155 160 156 // Get the user's handle for the redirect 161 157 const handle = context.currentUser?.handle; ··· 203 199 return renderHTML(<LexiconErrorMessage error={errorMessage} />); 204 200 } 205 201 } catch (error) { 206 - return renderHTML( 207 - <LexiconErrorMessage error={`Server error: ${error}`} />, 208 - ); 202 + return renderHTML(<LexiconErrorMessage error={`Server error: ${error}`} />); 209 203 } 210 204 } 211 205 212 206 async function handleViewLexicon( 213 207 req: Request, 214 - params?: URLPatternResult, 208 + params?: URLPatternResult 215 209 ): Promise<Response> { 216 210 const authContext = await withAuth(req); 217 211 const sliceParams = extractSliceParams(params); ··· 224 218 const context = await withSliceAccess( 225 219 authContext, 226 220 sliceParams.handle, 227 - sliceParams.sliceId, 221 + sliceParams.sliceId 228 222 ); 229 223 230 224 // Check if slice exists (lexicon detail page is public) ··· 233 227 } 234 228 235 229 try { 236 - const sliceClient = getSliceClient(authContext, sliceParams.sliceId, context.sliceContext.profileDid); 230 + const sliceClient = getSliceClient( 231 + authContext, 232 + sliceParams.sliceId, 233 + context.sliceContext.profileDid 234 + ); 237 235 238 - const lexiconRecords = await sliceClient.network.slices.lexicon 239 - .getRecords(); 236 + const lexiconRecords = 237 + await sliceClient.network.slices.lexicon.getRecords(); 240 238 241 239 const lexicon = lexiconRecords.records.find((record) => 242 240 record.uri.endsWith(`/${lexiconRkey}`) ··· 256 254 createdAt: lexicon.value.createdAt, 257 255 currentUser: authContext.currentUser, 258 256 hasSliceAccess: context.sliceContext?.hasAccess, 259 - }), 257 + }) 260 258 ); 261 259 } catch (error) { 262 260 console.error("Error viewing lexicon:", error); ··· 266 264 267 265 async function handleDeleteLexicon( 268 266 req: Request, 269 - params?: URLPatternResult, 267 + params?: URLPatternResult 270 268 ): Promise<Response> { 271 269 const context = await withAuth(req); 272 270 const authResponse = requireAuth(context); ··· 282 280 const sliceClient = getSliceClient(context, sliceId); 283 281 await sliceClient.network.slices.lexicon.deleteRecord(rkey); 284 282 285 - const remainingLexicons = await sliceClient.network.slices.lexicon 286 - .getRecords(); 283 + const remainingLexicons = 284 + await sliceClient.network.slices.lexicon.getRecords(); 287 285 288 286 if (remainingLexicons.records.length === 0) { 289 287 return renderHTML( ··· 297 295 headers: { 298 296 "HX-Retarget": "#lexicon-list", 299 297 }, 300 - }, 298 + } 301 299 ); 302 300 } else { 303 301 return new Response("", { ··· 311 309 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 312 310 <p>Failed to delete lexicon: {error}</p> 313 311 </div>, 314 - { status: 500 }, 312 + { status: 500 } 315 313 ); 316 314 } 317 315 } 318 316 319 317 async function handleToggleAllLexicons( 320 318 req: Request, 321 - params?: URLPatternResult, 319 + params?: URLPatternResult 322 320 ): Promise<Response> { 323 321 const context = await withAuth(req); 324 322 const authResponse = requireAuth(context); ··· 335 333 336 334 try { 337 335 const sliceClient = getSliceClient(context, sliceId); 338 - const lexiconRecords = await sliceClient.network.slices.lexicon 339 - .getRecords(); 336 + const lexiconRecords = 337 + await sliceClient.network.slices.lexicon.getRecords(); 340 338 341 339 if (lexiconRecords.records.length === 0) { 342 340 return renderHTML( ··· 345 343 title="No lexicons uploaded" 346 344 description="Upload lexicon definitions to define custom schemas for this slice." 347 345 withPadding 348 - />, 346 + /> 349 347 ); 350 348 } 351 349 ··· 363 361 handle={handle || undefined} 364 362 sliceDomain={sliceDomain} 365 363 hasSliceAccess 366 - />, 364 + /> 367 365 ); 368 366 } catch (error) { 369 367 console.error("Failed to toggle lexicons:", error); ··· 371 369 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 372 370 <p>Failed to load lexicons: {error}</p> 373 371 </div>, 374 - { status: 500 }, 372 + { status: 500 } 375 373 ); 376 374 } 377 375 } 378 376 379 377 async function handleBulkDeleteLexicons( 380 378 req: Request, 381 - params?: URLPatternResult, 379 + params?: URLPatternResult 382 380 ): Promise<Response> { 383 381 const context = await withAuth(req); 384 382 const authResponse = requireAuth(context); ··· 409 407 } 410 408 411 409 // Get remaining lexicons 412 - const remainingLexicons = await sliceClient.network.slices.lexicon 413 - .getRecords(); 410 + const remainingLexicons = 411 + await sliceClient.network.slices.lexicon.getRecords(); 414 412 415 413 if (remainingLexicons.records.length === 0) { 416 414 return renderHTML( ··· 419 417 title="No lexicons uploaded" 420 418 description="Upload lexicon definitions to define custom schemas for this slice." 421 419 withPadding 422 - />, 420 + /> 423 421 ); 424 422 } else { 425 423 // Get slice info for domain comparison ··· 436 434 handle={handle || undefined} 437 435 sliceDomain={sliceDomain} 438 436 hasSliceAccess 439 - />, 437 + /> 440 438 ); 441 439 } 442 440 } catch (error) { ··· 445 443 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 446 444 <p>Failed to delete lexicons: {error}</p> 447 445 </div>, 448 - { status: 500 }, 446 + { status: 500 } 449 447 ); 450 448 } 451 449 } 452 450 453 451 async function handleSliceLexiconPage( 454 452 req: Request, 455 - params?: URLPatternResult, 453 + params?: URLPatternResult 456 454 ): Promise<Response> { 457 455 const authContext = await withAuth(req); 458 456 const sliceParams = extractSliceParams(params); ··· 464 462 const context = await withSliceAccess( 465 463 authContext, 466 464 sliceParams.handle, 467 - sliceParams.sliceId, 465 + sliceParams.sliceId 468 466 ); 469 467 470 468 // Check if slice exists (lexicons page is public) ··· 472 470 return new Response("Slice not found", { status: 404 }); 473 471 } 474 472 473 + // Fetch lexicons 474 + let lexicons: RecordResponse<NetworkSlicesLexicon>[] = []; 475 + try { 476 + const sliceClient = getSliceClient( 477 + authContext, 478 + sliceParams.sliceId, 479 + context.sliceContext.profileDid 480 + ); 481 + const result = await sliceClient.network.slices.lexicon.getRecords(); 482 + lexicons = result.records || []; 483 + } catch (error) { 484 + console.error("Failed to fetch lexicons:", error); 485 + } 486 + 475 487 return renderHTML( 476 488 <SliceLexiconPage 477 489 slice={context.sliceContext!.slice!} 478 490 sliceId={sliceParams.sliceId} 491 + lexicons={lexicons} 479 492 currentUser={authContext.currentUser} 480 493 hasSliceAccess={context.sliceContext?.hasAccess} 481 - />, 494 + /> 482 495 ); 483 496 } 484 497 485 498 async function handleShowLexiconModal( 486 499 req: Request, 487 - params?: URLPatternResult, 500 + params?: URLPatternResult 488 501 ): Promise<Response> { 489 502 const context = await withAuth(req); 490 503 const authResponse = requireAuth(context);
+30 -28
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 - import { PageHeader } from "../../../../shared/fragments/PageHeader.tsx"; 4 3 import { Button } from "../../../../shared/fragments/Button.tsx"; 5 - import { Copy } from "lucide-preact"; 4 + import { Card } from "../../../../shared/fragments/Card.tsx"; 6 5 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 7 6 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 8 7 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; ··· 45 44 const lexiconJson = JSON.stringify(completeLexicon, null, 2); 46 45 const highlightedCode = await codeToHtml(lexiconJson, { 47 46 lang: "json", 48 - theme: "tokyo-night", 47 + themes: { 48 + light: "github-light", 49 + dark: "github-dark", 50 + }, 49 51 }); 50 52 51 53 return ( 52 54 <Layout title={`${nsid} - ${slice.name}`} currentUser={currentUser}> 53 55 <div className="max-w-6xl mx-auto px-4 py-8"> 54 - <Breadcrumb 55 - href={buildSliceUrlFromView(slice, sliceId, "lexicon")} 56 - label="Back to Lexicons" 57 - /> 58 - 59 56 <div className="flex items-center justify-between mb-6"> 60 - <h1 className="text-3xl font-bold text-zinc-900">{nsid}</h1> 57 + <div className="[&>div]:mb-0"> 58 + <Breadcrumb 59 + items={[ 60 + { 61 + label: slice.name, 62 + href: buildSliceUrlFromView(slice, sliceId), 63 + }, 64 + { 65 + label: "Lexicons", 66 + href: buildSliceUrlFromView(slice, sliceId, "lexicon"), 67 + }, 68 + { label: nsid }, 69 + ]} 70 + /> 71 + </div> 61 72 <Button 62 73 variant="secondary" 63 - onClick={`navigator.clipboard.writeText(${ 64 - JSON.stringify(lexiconJson) 65 - })`} 74 + _={`on click call navigator.clipboard.writeText(${JSON.stringify( 75 + lexiconJson 76 + )})`} 66 77 > 67 - <span className="flex items-center gap-2"> 68 - <Copy size={16} /> 69 - Copy JSON 70 - </span> 78 + <span className="flex items-center gap-2">Copy JSON</span> 71 79 </Button> 72 80 </div> 73 81 74 - <div className="bg-white border border-zinc-200"> 75 - <div className="px-6 py-4 border-b border-zinc-200"> 76 - <h2 className="text-lg font-semibold text-zinc-900"> 77 - Lexicon Definitions 78 - </h2> 79 - </div> 80 - 81 - <div 82 - className="text-sm overflow-x-auto [&_pre]:p-4" 83 - dangerouslySetInnerHTML={{ __html: highlightedCode }} 84 - /> 85 - </div> 82 + <Card padding="none"> 83 + <Card.Header title="Lexicon Definitions" /> 84 + <Card.Content className="text-sm overflow-x-auto [&_pre]:p-4 [&_pre]:m-0"> 85 + <div dangerouslySetInnerHTML={{ __html: highlightedCode }} /> 86 + </Card.Content> 87 + </Card> 86 88 </div> 87 89 </Layout> 88 90 );
+41 -26
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 { FileCode, Plus } from "lucide-preact"; 4 + import { Card } from "../../../../shared/fragments/Card.tsx"; 5 + import { LexiconsList } from "./fragments/LexiconsList.tsx"; 6 + import { FileCode } from "lucide-preact"; 5 7 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 6 - import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 8 + import type { 9 + NetworkSlicesSliceDefsSliceView, 10 + NetworkSlicesLexicon, 11 + } from "../../../../client.ts"; 12 + import type { RecordResponse } from "@slices/client"; 7 13 8 14 interface SliceLexiconPageProps { 9 15 slice: NetworkSlicesSliceDefsSliceView; 10 16 sliceId: string; 17 + lexicons?: RecordResponse<NetworkSlicesLexicon>[]; 11 18 currentUser?: AuthenticatedUser; 12 19 hasSliceAccess?: boolean; 13 20 } ··· 15 22 export function SliceLexiconPage({ 16 23 slice, 17 24 sliceId, 25 + lexicons = [], 18 26 currentUser, 19 27 hasSliceAccess, 20 28 }: SliceLexiconPageProps) { ··· 27 35 hasSliceAccess={hasSliceAccess} 28 36 title={`${slice.name} - Lexicons`} 29 37 > 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 34 - </h2> 35 - {hasSliceAccess && ( 38 + <div> 39 + {hasSliceAccess && ( 40 + <div className="flex justify-end mb-4"> 36 41 <Button 37 - variant="purple" 42 + variant="success" 38 43 hx-get={`/api/slices/${sliceId}/lexicons/modal`} 39 44 hx-target="#modal-container" 40 45 hx-swap="innerHTML" 41 46 > 42 - <span className="flex items-center"> 43 - <Plus size={16} className="mr-1" /> 44 - Add Lexicon 45 - </span> 47 + <span className="flex items-center">Add Lexicon</span> 46 48 </Button> 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 49 + </div> 50 + )} 51 + <Card padding="none"> 52 + <Card.Header 53 + title={`${lexicons.length} ${ 54 + lexicons.length === 1 ? "Lexicon" : "Lexicons" 55 + }`} 59 56 /> 60 - </div> 57 + <Card.Content id="lexicon-list"> 58 + {lexicons.length > 0 ? ( 59 + <LexiconsList 60 + records={lexicons} 61 + sliceId={sliceId} 62 + handle={slice.creator?.handle} 63 + sliceDomain={slice.domain} 64 + hasSliceAccess={hasSliceAccess} 65 + /> 66 + ) : ( 67 + <EmptyState 68 + icon={<FileCode size={64} strokeWidth={1} />} 69 + title="No lexicons uploaded" 70 + description="Upload lexicon definitions to define custom schemas for this slice." 71 + withPadding 72 + /> 73 + )} 74 + </Card.Content> 75 + </Card> 61 76 </div> 62 77 63 78 <div id="modal-container"></div>
+6 -4
frontend/src/features/slices/lexicon/templates/fragments/LexiconErrorMessage.tsx
··· 1 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 + 1 3 export function LexiconErrorMessage({ error }: { error: string }) { 2 4 return ( 3 - <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 5 + <div className="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded"> 4 6 <div className="flex"> 5 7 <div className="flex-shrink-0"> 6 8 <svg ··· 16 18 </svg> 17 19 </div> 18 20 <div className="ml-3"> 19 - <h3 className="text-sm font-medium">Error creating lexicon</h3> 20 - <div className="mt-2 text-sm"> 21 - <p>{error}</p> 21 + <Text as="h3" size="sm" className="font-medium">Error creating lexicon</Text> 22 + <div className="mt-2"> 23 + <Text as="p" size="sm">{error}</Text> 22 24 </div> 23 25 </div> 24 26 </div>
+7 -4
frontend/src/features/slices/lexicon/templates/fragments/LexiconFormModal.tsx
··· 1 1 import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 2 import { Textarea } from "../../../../../shared/fragments/Textarea.tsx"; 3 3 import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 4 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 5 5 6 interface LexiconFormModalProps { 6 7 sliceId: string; ··· 51 52 }`} 52 53 required 53 54 /> 54 - <p className="text-sm text-zinc-500 mt-1"> 55 + <Text as="p" size="sm" variant="muted" className="mt-1"> 55 56 Paste a valid AT Protocol lexicon definition in JSON format 56 - </p> 57 + </Text> 57 58 </div> 58 59 59 60 <div id="lexicon-result"></div> ··· 68 69 </Button> 69 70 <Button 70 71 type="submit" 71 - variant="purple" 72 + variant="success" 72 73 hx-indicator="#lexicon-loading" 73 74 > 74 - <span id="lexicon-loading" class="htmx-indicator">Adding...</span> 75 + <span id="lexicon-loading" class="htmx-indicator"> 76 + Adding... 77 + </span> 75 78 <span class="default-text">Add Lexicon</span> 76 79 </Button> 77 80 </div>
+35 -43
frontend/src/features/slices/lexicon/templates/fragments/LexiconListItem.tsx
··· 1 1 import { getRkeyFromUri } from "../../../../../utils/at-uri.ts"; 2 - import { ChevronRight } from "lucide-preact"; 3 2 import { buildSliceUrl } from "../../../../../utils/slice-params.ts"; 4 - import { cn } from "../../../../../utils/cn.ts"; 3 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 + import { Link } from "../../../../../shared/fragments/Link.tsx"; 5 + import { ListItem } from "../../../../../shared/fragments/ListItem.tsx"; 6 + import { Badge } from "../../../../../shared/fragments/Badge.tsx"; 5 7 6 8 export function LexiconListItem({ 7 9 nsid, ··· 21 23 const rkey = getRkeyFromUri(uri); 22 24 23 25 return ( 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 - )} 37 - <a 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")} 42 - > 43 - <div className="flex justify-between items-center"> 44 - <div> 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> 26 + <ListItem id={`lexicon-${rkey}`}> 27 + <div className="flex items-center w-full"> 28 + {hasSliceAccess && ( 29 + <div className="px-6 py-4"> 30 + <input 31 + type="checkbox" 32 + name="lexicon_rkey" 33 + value={rkey} 34 + className="accent-blue-600 dark:accent-white" 35 + style="color-scheme: light dark;" 36 + /> 61 37 </div> 62 - <div className="text-zinc-400"> 63 - <ChevronRight size={20} /> 38 + )} 39 + <div className={`flex-1 pr-6 py-4 ${!hasSliceAccess ? "pl-6" : ""}`}> 40 + <div className="flex items-center gap-2"> 41 + <Link 42 + href={handle 43 + ? buildSliceUrl(handle, sliceId, `lexicons/${rkey}`) 44 + : `/slices/${sliceId}/lexicons/${rkey}`} 45 + variant="inherit" 46 + > 47 + <Text as="span" size="base" className="font-medium"> 48 + {nsid} 49 + </Text> 50 + </Link> 51 + {isPrimary !== undefined && ( 52 + <Badge variant={isPrimary ? "success" : "primary"}> 53 + {isPrimary ? "Primary" : "External"} 54 + </Badge> 55 + )} 64 56 </div> 65 57 </div> 66 - </a> 67 - </div> 58 + </div> 59 + </ListItem> 68 60 ); 69 61 }
+10 -8
frontend/src/features/slices/lexicon/templates/fragments/LexiconSuccessMessage.tsx
··· 1 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 + 1 3 export function LexiconSuccessMessage({ 2 4 nsid, 3 5 uri, ··· 9 11 }) { 10 12 return ( 11 13 <div 12 - className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 mb-4" 14 + className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300 px-4 py-3 mb-4" 13 15 hx-trigger="load" 14 16 hx-get={`/api/slices/${sliceId}/lexicons/list`} 15 17 hx-target="#lexicon-list" ··· 30 32 </svg> 31 33 </div> 32 34 <div className="ml-3"> 33 - <h3 className="text-sm font-medium">Lexicon created successfully!</h3> 34 - <div className="mt-2 text-sm"> 35 - <p> 35 + <Text as="h3" size="sm" className="font-medium">Lexicon created successfully!</Text> 36 + <div className="mt-2"> 37 + <Text as="p" size="sm"> 36 38 <strong>NSID:</strong> {nsid} 37 - </p> 38 - <p> 39 + </Text> 40 + <Text as="p" size="sm"> 39 41 <strong>URI:</strong>{" "} 40 - <code className="bg-zinc-100 px-2 py-1 rounded text-xs"> 42 + <code className="bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded text-xs"> 41 43 {uri} 42 44 </code> 43 - </p> 45 + </Text> 44 46 </div> 45 47 </div> 46 48 </div>
+48 -40
frontend/src/features/slices/lexicon/templates/fragments/LexiconsList.tsx
··· 1 1 import { LexiconListItem } from "./LexiconListItem.tsx"; 2 - import { Trash2 } from "lucide-preact"; 2 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 3 + import type { RecordResponse } from "@slices/client"; 3 4 4 - interface LexiconRecord { 5 - uri: string; 6 - value: { 7 - nsid: string; 8 - }; 9 - } 5 + import type { NetworkSlicesLexicon } from "../../../../../client.ts"; 10 6 11 7 interface LexiconsListProps { 12 - records: LexiconRecord[]; 8 + records: RecordResponse<NetworkSlicesLexicon>[]; 13 9 sliceId: string; 14 10 handle?: string; 15 11 sliceDomain?: string; 16 12 hasSliceAccess?: boolean; 17 13 } 18 14 19 - function organizeLexicons(records: LexiconRecord[], sliceDomain?: string) { 20 - const primaryLexicons: LexiconRecord[] = []; 21 - const externalLexicons: LexiconRecord[] = []; 15 + function organizeLexicons( 16 + records: RecordResponse<NetworkSlicesLexicon>[], 17 + sliceDomain?: string 18 + ) { 19 + const primaryLexicons: RecordResponse<NetworkSlicesLexicon>[] = []; 20 + const externalLexicons: RecordResponse<NetworkSlicesLexicon>[] = []; 22 21 23 22 records.forEach((record) => { 24 23 if (sliceDomain && record.value.nsid.startsWith(sliceDomain)) { ··· 31 30 return { primaryLexicons, externalLexicons }; 32 31 } 33 32 34 - export function LexiconsList( 35 - { records, sliceId, handle, sliceDomain, hasSliceAccess }: LexiconsListProps, 36 - ) { 33 + export function LexiconsList({ 34 + records, 35 + sliceId, 36 + handle, 37 + sliceDomain, 38 + hasSliceAccess, 39 + }: LexiconsListProps) { 37 40 const { primaryLexicons, externalLexicons } = organizeLexicons( 38 41 records, 39 - sliceDomain, 42 + sliceDomain 40 43 ); 41 44 42 45 return ( 43 46 <div> 44 47 {/* Bulk actions bar - only show for slice owners */} 45 48 {hasSliceAccess && ( 46 - <div className="px-6 py-3 border-b border-zinc-200 flex items-center justify-between"> 49 + <div className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between"> 47 50 <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 - /> 51 + <div className="px-6 py-4"> 52 + <input 53 + type="checkbox" 54 + id="select-all" 55 + className="accent-blue-600 dark:accent-white" 56 + style="color-scheme: light dark;" 57 + _="on change 58 + set checkboxes to document.querySelectorAll('input[name=lexicon_rkey]') 59 + for cb in checkboxes 60 + set cb.checked to my.checked 61 + end 62 + " 63 + /> 64 + </div> 59 65 <label 60 66 htmlFor="select-all" 61 - className="text-sm font-medium text-zinc-700" 67 + className="text-sm font-medium text-zinc-700 dark:text-zinc-300" 62 68 > 63 69 Select All 64 70 </label> 65 71 </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> 72 + <div className="pr-6 py-4"> 73 + <Button 74 + variant="outline" 75 + size="sm" 76 + hx-delete={`/api/slices/${sliceId}/lexicons/bulk`} 77 + hx-target="#lexicon-list" 78 + hx-swap="outerHTML" 79 + hx-include="input[name='lexicon_rkey']:checked" 80 + hx-confirm="Are you sure you want to delete the selected lexicons?" 81 + > 82 + <span className="flex items-center">Delete Selected</span> 83 + </Button> 84 + </div> 77 85 </div> 78 86 )} 79 87 80 88 {/* List of lexicons */} 81 - <div id="lexicon-list" className="divide-y divide-zinc-200"> 89 + <div id="lexicon-list"> 82 90 {primaryLexicons.length > 0 && ( 83 91 <> 84 92 {primaryLexicons.map((record) => (
+79 -28
frontend/src/features/slices/oauth/handlers.tsx
··· 7 7 withSliceAccess, 8 8 } from "../../../routes/slice-middleware.ts"; 9 9 import { 10 - buildSliceUrl, 11 10 extractSliceParams, 12 11 } from "../../../utils/slice-params.ts"; 13 12 import { renderHTML } from "../../../utils/render.tsx"; 14 - import { hxRedirect } from "../../../utils/htmx.ts"; 15 13 import { SliceOAuthPage } from "./templates/SliceOAuthPage.tsx"; 16 14 import { OAuthClientModal } from "./templates/fragments/OAuthClientModal.tsx"; 17 - import { OAuthRegistrationResult } from "./templates/fragments/OAuthRegistrationResult.tsx"; 15 + import { OAuthResult } from "./templates/fragments/OAuthResult.tsx"; 18 16 import { OAuthDeleteResult } from "./templates/fragments/OAuthDeleteResult.tsx"; 19 17 20 18 async function handleOAuthClientNew(req: Request): Promise<Response> { ··· 84 82 85 83 // Register new OAuth client via backend API 86 84 const sliceClient = getSliceClient(context, sliceId); 87 - await sliceClient.network.slices.slice.createOAuthClient({ 85 + const result = await sliceClient.network.slices.slice.createOAuthClient({ 88 86 clientName, 89 87 redirectUris, 90 88 scope: scope || undefined, ··· 94 92 policyUri: policyUri || undefined, 95 93 }); 96 94 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"); 95 + // Check if the result indicates success/failure 96 + if (typeof result === 'object' && 'success' in result && result.success === false) { 97 + return renderHTML( 98 + <OAuthResult 99 + success={false} 100 + message={result.message || "OAuth client registration failed"} 101 + /> 102 + ); 101 103 } 102 104 103 - // Redirect to the OAuth page to show the new client 104 - const redirectUrl = buildSliceUrl(handle, sliceId, "oauth"); 105 - return hxRedirect(redirectUrl); 105 + return renderHTML( 106 + <OAuthResult 107 + success={true} 108 + message="OAuth client registered successfully" 109 + /> 110 + ); 106 111 } catch (error) { 107 112 console.error("Error registering OAuth client:", error); 113 + let errorMessage = `Failed to register OAuth client: ${error}`; 114 + 115 + if (error instanceof Error) { 116 + try { 117 + const errorResponse = JSON.parse(error.message); 118 + if (errorResponse.error_description) { 119 + errorMessage = errorResponse.error_description; 120 + } else if (errorResponse.error) { 121 + errorMessage = errorResponse.error; 122 + } 123 + } catch { 124 + // If we can't parse JSON, try to extract from the error message 125 + const errorStr = error.message; 126 + if (errorStr.includes("Invalid redirect URI")) { 127 + errorMessage = "Invalid redirect URI format. Please ensure all redirect URIs use proper HTTP/HTTPS format."; 128 + } else if (errorStr.includes("Bad Request")) { 129 + errorMessage = errorStr; 130 + } 131 + } 132 + } 133 + 108 134 return renderHTML( 109 - <OAuthRegistrationResult 135 + <OAuthResult 110 136 success={false} 111 - error={error instanceof Error ? error.message : String(error)} 112 - sliceId={sliceId} 113 - />, 114 - { status: 500 }, 137 + message={errorMessage} 138 + /> 115 139 ); 116 140 } 117 141 } ··· 227 251 228 252 // Update OAuth client via backend API 229 253 const sliceClient = getSliceClient(context, sliceId); 230 - const updatedClient = await sliceClient.network.slices.slice 254 + const result = await sliceClient.network.slices.slice 231 255 .updateOAuthClient({ 232 256 clientId, 233 257 clientName: clientName || undefined, ··· 239 263 policyUri: policyUri || undefined, 240 264 }); 241 265 242 - const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 266 + // Check if the result indicates success/failure 267 + if (typeof result === 'object' && 'success' in result && result.success === false) { 268 + return renderHTML( 269 + <OAuthResult 270 + success={false} 271 + message={result.message || "OAuth client update failed"} 272 + /> 273 + ); 274 + } 275 + 243 276 return renderHTML( 244 - <OAuthClientModal 245 - sliceId={sliceId} 246 - sliceUri={sliceUri} 247 - mode="view" 248 - clientData={updatedClient} 249 - />, 277 + <OAuthResult 278 + success={true} 279 + message="OAuth client updated successfully" 280 + /> 250 281 ); 251 282 } catch (error) { 252 283 console.error("Error updating OAuth client:", error); 284 + let errorMessage = `Failed to update OAuth client: ${error}`; 285 + 286 + if (error instanceof Error) { 287 + try { 288 + const errorResponse = JSON.parse(error.message); 289 + if (errorResponse.error_description) { 290 + errorMessage = errorResponse.error_description; 291 + } else if (errorResponse.error) { 292 + errorMessage = errorResponse.error; 293 + } 294 + } catch { 295 + // If we can't parse JSON, try to extract from the error message 296 + const errorStr = error.message; 297 + if (errorStr.includes("Invalid redirect URI")) { 298 + errorMessage = "Invalid redirect URI format. Please ensure all redirect URIs use proper HTTP/HTTPS format."; 299 + } else if (errorStr.includes("Bad Request")) { 300 + errorMessage = errorStr; 301 + } 302 + } 303 + } 304 + 253 305 return renderHTML( 254 - <OAuthDeleteResult 306 + <OAuthResult 255 307 success={false} 256 - error={error instanceof Error ? error.message : String(error)} 257 - />, 258 - { status: 500 }, 308 + message={errorMessage} 309 + /> 259 310 ); 260 311 } 261 312 }
+40 -43
frontend/src/features/slices/oauth/templates/SliceOAuthPage.tsx
··· 1 1 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 2 2 import { Button } from "../../../../shared/fragments/Button.tsx"; 3 3 import { EmptyState } from "../../../../shared/fragments/EmptyState.tsx"; 4 + import { FlashMessage } from "../../../../shared/fragments/FlashMessage.tsx"; 5 + import { Card } from "../../../../shared/fragments/Card.tsx"; 4 6 import { OAuthClientsList } from "./fragments/OAuthClientsList.tsx"; 5 7 import { Key } from "lucide-preact"; 6 8 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; ··· 42 44 title={`${slice.name} - OAuth Clients`} 43 45 > 44 46 {success && ( 45 - <div className="bg-green-50 border border-green-200 px-4 py-3 mb-4"> 46 - ✅ {success} 47 - </div> 47 + <FlashMessage type="success" message={success} className="mb-4" /> 48 48 )} 49 49 50 50 {error && ( 51 - <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 mb-4"> 52 - ❌ {error} 53 - </div> 51 + <FlashMessage type="error" message={error} className="mb-4" /> 54 52 )} 55 53 56 - <div className="bg-white border border-zinc-200"> 57 - <div className="px-6 py-4 border-b border-zinc-200"> 58 - <div className="flex justify-between items-center"> 59 - <h2 className="text-lg font-semibold text-zinc-900"> 60 - OAuth Clients 61 - </h2> 62 - <Button 63 - type="button" 64 - variant="primary" 65 - hx-get={`/api/slices/${sliceId}/oauth/new`} 66 - hx-target="#modal-container" 67 - hx-swap="innerHTML" 68 - > 69 - Register New Client 70 - </Button> 71 - </div> 72 - </div> 54 + <div className="flex justify-between items-center mb-4"> 55 + <div></div> 56 + <Button 57 + type="button" 58 + variant="success" 59 + hx-get={`/api/slices/${sliceId}/oauth/new`} 60 + hx-target="#modal-container" 61 + hx-swap="innerHTML" 62 + > 63 + Register Client 64 + </Button> 65 + </div> 73 66 74 - {clients.length === 0 75 - ? ( 76 - <EmptyState 77 - icon={<Key size={64} strokeWidth={1} />} 78 - title="No OAuth clients registered" 79 - description="Register OAuth clients to allow applications to access your slice data." 80 - withPadding 81 - > 82 - <Button 83 - type="button" 84 - variant="primary" 85 - hx-get={`/api/slices/${sliceId}/oauth/new`} 86 - hx-target="#modal-container" 87 - hx-swap="innerHTML" 67 + <Card padding="none"> 68 + <Card.Header title="OAuth Clients" /> 69 + <Card.Content> 70 + {clients.length === 0 71 + ? ( 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 88 77 > 89 - Register Your First Client 90 - </Button> 91 - </EmptyState> 92 - ) 93 - : <OAuthClientsList clients={clients} sliceId={sliceId} />} 94 - </div> 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" 84 + > 85 + Register Client 86 + </Button> 87 + </EmptyState> 88 + ) 89 + : <OAuthClientsList clients={clients} sliceId={sliceId} />} 90 + </Card.Content> 91 + </Card> 95 92 96 93 <div id="modal-container"></div> 97 94 </SlicePage>
+34 -32
frontend/src/features/slices/oauth/templates/fragments/OAuthClientModal.tsx
··· 3 3 import { Input } from "../../../../../shared/fragments/Input.tsx"; 4 4 import { Textarea } from "../../../../../shared/fragments/Textarea.tsx"; 5 5 import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 6 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 6 7 7 8 interface OAuthClientModalProps { 8 9 sliceId: string; ··· 21 22 return ( 22 23 <Modal title="OAuth Client Details"> 23 24 <form 24 - hx-post={`/api/slices/${sliceId}/oauth/${ 25 - encodeURIComponent(clientData.clientId) 26 - }/update`} 27 - hx-target="#modal-container" 28 - hx-swap="outerHTML" 25 + hx-post={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 26 + clientData.clientId 27 + )}/update`} 28 + hx-target="#oauth-result" 29 + hx-swap="innerHTML" 29 30 > 30 31 <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> 38 - </div> 32 + <Input 33 + id="clientId" 34 + name="clientId" 35 + label="Client ID" 36 + value={clientData.clientId} 37 + disabled 38 + className="font-mono" 39 + /> 39 40 40 41 {clientData.clientSecret && ( 41 - <div> 42 - <label className="block text-sm font-medium text-gray-700 mb-1"> 43 - Client Secret 44 - </label> 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 48 - </div> 49 - {clientData.clientSecret} 50 - </div> 51 - </div> 42 + <Input 43 + id="clientSecret" 44 + name="clientSecret" 45 + label="Client Secret" 46 + value={clientData.clientSecret} 47 + disabled 48 + className="font-mono" 49 + /> 52 50 )} 53 51 54 52 <Input ··· 68 66 rows={3} 69 67 defaultValue={clientData.redirectUris.join("\n")} 70 68 /> 71 - <p className="text-sm text-gray-500 mt-1"> 69 + <Text as="p" size="xs" variant="muted" className="mt-1"> 72 70 Enter one redirect URI per line 73 - </p> 71 + </Text> 74 72 </div> 75 73 76 74 <Input ··· 117 115 placeholder="https://example.com/privacy" 118 116 /> 119 117 118 + <div id="oauth-result"></div> 119 + 120 120 <div className="flex justify-end gap-3 mt-6"> 121 121 <Button 122 122 type="button" ··· 125 125 > 126 126 Cancel 127 127 </Button> 128 - <Button type="submit" variant="primary"> 128 + <Button type="submit" variant="success"> 129 129 Update Client 130 130 </Button> 131 131 </div> ··· 139 139 <Modal title="Register OAuth Client"> 140 140 <form 141 141 hx-post={`/api/slices/${sliceId}/oauth/register`} 142 - hx-target="#modal-container" 143 - hx-swap="outerHTML" 142 + hx-target="#oauth-result" 143 + hx-swap="innerHTML" 144 144 > 145 145 <input type="hidden" name="sliceUri" value={sliceUri} /> 146 146 ··· 162 162 rows={3} 163 163 placeholder="https://example.com/callback&#10;https://localhost:3000/callback" 164 164 /> 165 - <p className="text-sm text-gray-500 mt-1"> 165 + <Text as="p" size="xs" variant="muted" className="mt-1"> 166 166 Enter one redirect URI per line 167 - </p> 167 + </Text> 168 168 </div> 169 169 170 170 <Input ··· 206 206 placeholder="https://example.com/privacy" 207 207 /> 208 208 209 + <div id="oauth-result"></div> 210 + 209 211 <div className="flex justify-end gap-3 mt-6"> 210 212 <Button 211 213 type="button" ··· 214 216 > 215 217 Cancel 216 218 </Button> 217 - <Button type="submit" variant="primary"> 219 + <Button type="submit" variant="success"> 218 220 Register Client 219 221 </Button> 220 222 </div>
+19 -16
frontend/src/features/slices/oauth/templates/fragments/OAuthClientsList.tsx
··· 1 1 import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 3 3 4 interface OAuthClient { 4 5 clientId: string; ··· 14 15 15 16 export function OAuthClientsList({ clients, sliceId }: OAuthClientsListProps) { 16 17 return ( 17 - <div className="divide-y divide-zinc-200"> 18 + <div className="divide-y divide-zinc-200 dark:divide-zinc-700"> 18 19 {clients.map((client) => ( 19 20 <div 20 21 key={client.clientId} 21 - className="oauth-client-item px-6 py-4 hover:bg-zinc-50 transition-colors" 22 + className="oauth-client-item px-6 py-4 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors" 22 23 > 23 24 <div className="flex justify-between items-start"> 24 25 <div className="flex-1"> 25 26 <div className="flex items-center gap-3 mb-2"> 26 - <h3 className="font-medium text-zinc-900"> 27 + <Text as="h3" size="base" variant="primary" className="font-medium"> 27 28 {client.clientName || "Unnamed Client"} 28 - </h3> 29 - <span className="text-xs text-zinc-400 font-mono bg-zinc-100 px-2 py-1 rounded"> 29 + </Text> 30 + <span className="text-xs text-zinc-400 dark:text-zinc-500 font-mono bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded"> 30 31 {client.clientId} 31 32 </span> 32 33 </div> 33 - <div className="text-sm text-zinc-500 space-y-1"> 34 - <div> 34 + <div className="text-sm space-y-1"> 35 + <Text as="div" size="sm" variant="secondary"> 35 36 Created {new Date(client.createdAt).toLocaleDateString()} 36 - </div> 37 + </Text> 37 38 {client.redirectUris && ( 38 39 <div> 39 - <span className="font-medium">Redirect URIs:</span>{" "} 40 - {client.redirectUris.slice(0, 2).join(", ")} 40 + <Text as="span" size="sm" variant="secondary" className="font-medium"> 41 + Redirect URIs: 42 + </Text> 43 + <Text as="span" size="sm" variant="secondary"> 44 + {" " + client.redirectUris.slice(0, 2).join(", ")} 45 + </Text> 41 46 {client.redirectUris.length > 2 && ( 42 - <span className="text-zinc-400"> 47 + <Text as="span" size="sm" variant="muted"> 43 48 &nbsp;+{client.redirectUris.length - 2} more 44 - </span> 49 + </Text> 45 50 )} 46 51 </div> 47 52 )} ··· 50 55 <div className="flex gap-2 ml-4"> 51 56 <Button 52 57 type="button" 53 - variant="ghost" 58 + variant="outline" 54 59 size="sm" 55 60 hx-get={`/api/slices/${sliceId}/oauth/${ 56 61 encodeURIComponent( ··· 59 64 }/view`} 60 65 hx-target="#modal-container" 61 66 hx-swap="innerHTML" 62 - className="text-purple-600 hover:text-purple-800" 63 67 > 64 68 View 65 69 </Button> 66 70 <Button 67 71 type="button" 68 - variant="ghost" 72 + variant="danger" 69 73 size="sm" 70 74 hx-delete={`/api/slices/${sliceId}/oauth/${ 71 75 encodeURIComponent( ··· 75 79 hx-confirm="Are you sure you want to delete this OAuth client?" 76 80 hx-target="closest .oauth-client-item" 77 81 hx-swap="outerHTML" 78 - className="text-red-600 hover:text-red-800" 79 82 > 80 83 Delete 81 84 </Button>
+7 -3
frontend/src/features/slices/oauth/templates/fragments/OAuthDeleteResult.tsx
··· 1 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 + 1 3 interface OAuthDeleteResultProps { 2 4 success: boolean; 3 5 error?: string; ··· 5 7 6 8 export function OAuthDeleteResult({ success, error }: OAuthDeleteResultProps) { 7 9 if (success) { 8 - return <></>; 10 + return null; 9 11 } 10 12 11 13 return ( 12 14 <tr> 13 - <td colSpan={5} className="py-3 px-4 text-center text-red-600"> 14 - Failed to delete client: {error || "Unknown error"} 15 + <td colSpan={5} className="py-3 px-4 text-center"> 16 + <Text variant="error"> 17 + Failed to delete client: {error || "Unknown error"} 18 + </Text> 15 19 </td> 16 20 </tr> 17 21 );
+27 -24
frontend/src/features/slices/oauth/templates/fragments/OAuthRegistrationResult.tsx
··· 1 1 import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 3 + import { Input } from "../../../../../shared/fragments/Input.tsx"; 4 + import { FlashMessage } from "../../../../../shared/fragments/FlashMessage.tsx"; 2 5 3 6 interface OAuthRegistrationResultProps { 4 7 success: boolean; ··· 15 18 }: OAuthRegistrationResultProps) { 16 19 if (success) { 17 20 return ( 18 - <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 19 - <div className="bg-white rounded-lg p-6 max-w-md w-full"> 20 - <h2 className="text-xl font-semibold text-gray-800 mb-4"> 21 - OAuth Client Registered 22 - </h2> 23 - <p className="text-gray-600 mb-4"> 24 - Your OAuth client has been successfully registered. 25 - </p> 21 + <Modal title="OAuth Client Registered"> 22 + <div className="space-y-4"> 23 + <FlashMessage 24 + type="success" 25 + message="Your OAuth client has been successfully registered." 26 + /> 26 27 {clientId && ( 27 - <div className="bg-gray-50 rounded p-3 mb-4"> 28 - <p className="text-sm text-gray-700 font-medium">Client ID:</p> 29 - <p className="font-mono text-sm">{clientId}</p> 30 - </div> 28 + <Input 29 + id="clientId" 30 + name="clientId" 31 + label="Client ID" 32 + value={clientId} 33 + disabled 34 + className="font-mono" 35 + /> 31 36 )} 32 - <div className="flex justify-end gap-3"> 37 + <div className="flex justify-end gap-3 mt-6"> 33 38 <Button 34 39 type="button" 35 40 variant="primary" ··· 42 47 </Button> 43 48 </div> 44 49 </div> 45 - </div> 50 + </Modal> 46 51 ); 47 52 } 48 53 49 54 return ( 50 - <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 51 - <div className="bg-white rounded-lg p-6 max-w-md w-full"> 52 - <h2 className="text-xl font-semibold text-red-600 mb-4"> 53 - Registration Failed 54 - </h2> 55 - <p className="text-gray-600 mb-4"> 56 - Failed to register OAuth client: {error || "Unknown error"} 57 - </p> 58 - <div className="flex justify-end gap-3"> 55 + <Modal title="Registration Failed"> 56 + <div className="space-y-4"> 57 + <FlashMessage 58 + type="error" 59 + message={`Failed to register OAuth client: ${error || "Unknown error"}`} 60 + /> 61 + <div className="flex justify-end gap-3 mt-6"> 59 62 <Button 60 63 type="button" 61 64 variant="secondary" ··· 65 68 </Button> 66 69 </div> 67 70 </div> 68 - </div> 71 + </Modal> 69 72 ); 70 73 }
+51
frontend/src/features/slices/oauth/templates/fragments/OAuthResult.tsx
··· 1 + import { FlashMessage } from "../../../../../shared/fragments/FlashMessage.tsx"; 2 + import { Input } from "../../../../../shared/fragments/Input.tsx"; 3 + 4 + interface OAuthResultProps { 5 + success: boolean; 6 + message?: string; 7 + clientId?: string; 8 + clientSecret?: string; 9 + } 10 + 11 + export function OAuthResult({ success, message, clientId, clientSecret }: OAuthResultProps) { 12 + console.log("OAuthResult rendered:", { success, message, clientId, clientSecret }); 13 + 14 + if (success) { 15 + return ( 16 + <div className="space-y-4"> 17 + <FlashMessage 18 + type="success" 19 + message={message || "OAuth client operation completed successfully"} 20 + /> 21 + {clientId && ( 22 + <Input 23 + id="resultClientId" 24 + name="resultClientId" 25 + label="Client ID" 26 + value={clientId} 27 + disabled 28 + className="font-mono" 29 + /> 30 + )} 31 + {clientSecret && ( 32 + <Input 33 + id="resultClientSecret" 34 + name="resultClientSecret" 35 + label="Client Secret" 36 + value={clientSecret} 37 + disabled 38 + className="font-mono" 39 + /> 40 + )} 41 + </div> 42 + ); 43 + } 44 + 45 + return ( 46 + <FlashMessage 47 + type="error" 48 + message={message || "OAuth client operation failed"} 49 + /> 50 + ); 51 + }
+127 -121
frontend/src/features/slices/overview/templates/SliceOverview.tsx
··· 2 2 import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 3 3 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 4 4 import { Button } from "../../../../shared/fragments/Button.tsx"; 5 + import { Card } from "../../../../shared/fragments/Card.tsx"; 6 + import { Text } from "../../../../shared/fragments/Text.tsx"; 7 + import { Link } from "../../../../shared/fragments/Link.tsx"; 5 8 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 9 + 10 + function formatNumber(num: number): string { 11 + return num.toLocaleString(); 12 + } 6 13 7 14 interface Collection { 8 15 name: string; ··· 40 47 hx-trigger="load, every 2m" 41 48 hx-swap="outerHTML" 42 49 > 43 - <div className="bg-zinc-50 border border-zinc-200 p-4 mb-6"> 50 + <Card padding="sm" className="mb-6"> 44 51 <div className="flex items-center justify-between"> 45 52 <div className="flex items-center"> 46 - <div className="w-3 h-3 bg-zinc-400 rounded-full mr-3"></div> 53 + <div className="w-3 h-3 bg-zinc-400 dark:bg-zinc-500 rounded-full mr-3"></div> 47 54 <div> 48 - <h3 className="text-sm font-semibold text-zinc-600"> 55 + <Text 56 + as="h3" 57 + size="sm" 58 + variant="secondary" 59 + className="font-semibold block" 60 + > 49 61 🌊 Checking Jetstream Status... 50 - </h3> 51 - <p className="text-xs text-zinc-500"> 62 + </Text> 63 + <Text as="p" size="xs" variant="muted"> 52 64 Loading connection status 53 - </p> 65 + </Text> 54 66 </div> 55 67 </div> 56 - <div className="text-xs text-zinc-500">Checking...</div> 68 + <Text as="span" size="xs" variant="muted"> 69 + Checking... 70 + </Text> 57 71 </div> 58 - </div> 72 + </Card> 59 73 </div> 60 74 61 75 {(slice.indexedRecordCount ?? 0) > 0 && ( 62 - <div className="bg-sky-50 border border-sky-200 p-6 mb-8"> 63 - <h2 className="text-xl font-semibold text-zinc-900 mb-2"> 76 + <Card className="mb-8"> 77 + <Text as="h2" size="xl" className="font-semibold mb-4"> 64 78 📊 Database Status 65 - </h2> 66 - <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-zinc-700"> 79 + </Text> 80 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 67 81 <div> 68 - <span className="text-2xl font-bold">{slice.indexedRecordCount ?? 0}</span> 69 - <p className="text-sm">Records</p> 82 + <Text as="span" size="2xl" className="font-bold block"> 83 + {formatNumber(slice.indexedRecordCount ?? 0)} 84 + </Text> 85 + <Text as="span" size="sm" variant="secondary" className="block"> 86 + Records 87 + </Text> 70 88 </div> 71 89 <div> 72 - <span className="text-2xl font-bold">{slice.indexedCollectionCount ?? 0}</span> 73 - <p className="text-sm">Collections</p> 90 + <Text as="span" size="2xl" className="font-bold block"> 91 + {formatNumber(slice.indexedCollectionCount ?? 0)} 92 + </Text> 93 + <Text as="span" size="sm" variant="secondary" className="block"> 94 + Collections 95 + </Text> 74 96 </div> 75 97 <div> 76 - <span className="text-2xl font-bold">{slice.indexedActorCount ?? 0}</span> 77 - <p className="text-sm">Actors</p> 98 + <Text as="span" size="2xl" className="font-bold block"> 99 + {formatNumber(slice.indexedActorCount ?? 0)} 100 + </Text> 101 + <Text as="span" size="sm" variant="secondary" className="block"> 102 + Actors 103 + </Text> 78 104 </div> 79 105 </div> 80 - </div> 106 + </Card> 81 107 )} 82 108 83 109 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 84 - <div className="bg-white border border-zinc-200 p-6"> 85 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 110 + <Card> 111 + <Text as="h2" size="xl" className="font-semibold mb-4 block"> 86 112 📚 Lexicon Definitions 87 - </h2> 88 - <p className="text-zinc-600 mb-4"> 113 + </Text> 114 + <Text as="p" variant="secondary" className="mb-4"> 89 115 View lexicon definitions and schemas that define your slice. 90 - </p> 116 + </Text> 91 117 <Button 92 118 href={buildSliceUrlFromView(slice, sliceId, "lexicon")} 93 - variant="purple" 119 + variant="primary" 94 120 > 95 121 View Lexicons 96 122 </Button> 97 - </div> 123 + </Card> 98 124 99 - <div className="bg-white border border-zinc-200 p-6"> 100 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 125 + <Card> 126 + <Text as="h2" size="xl" className="font-semibold mb-4 block"> 101 127 📝 View Records 102 - </h2> 103 - <p className="text-zinc-600 mb-4"> 128 + </Text> 129 + <Text as="p" variant="secondary" className="mb-4"> 104 130 Browse indexed AT Protocol records by collection. 105 - </p> 106 - {collections.length > 0 107 - ? ( 108 - <Button 109 - href={buildSliceUrlFromView(slice, sliceId, "records")} 110 - variant="primary" 111 - > 112 - Browse Records 113 - </Button> 114 - ) 115 - : ( 116 - <p className="text-zinc-500 text-sm"> 117 - No records synced yet. Start by syncing some records! 118 - </p> 119 - )} 120 - </div> 131 + </Text> 132 + {collections.length > 0 ? ( 133 + <Button 134 + href={buildSliceUrlFromView(slice, sliceId, "records")} 135 + variant="primary" 136 + > 137 + Browse Records 138 + </Button> 139 + ) : ( 140 + <Text as="p" variant="muted" size="sm"> 141 + No records synced yet. Start by syncing some records! 142 + </Text> 143 + )} 144 + </Card> 121 145 122 - <div className="bg-white border border-zinc-200 p-6"> 123 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 146 + <Card> 147 + <Text as="h2" size="xl" className="font-semibold mb-4 block"> 124 148 ⚡ Code Generation 125 - </h2> 126 - <p className="text-zinc-600 mb-4"> 149 + </Text> 150 + <Text as="p" variant="secondary" className="mb-4"> 127 151 Generate TypeScript client from your lexicon definitions. 128 - </p> 152 + </Text> 129 153 <Button 130 154 href={buildSliceUrlFromView(slice, sliceId, "codegen")} 131 - variant="warning" 155 + variant="primary" 132 156 > 133 157 Generate Client 134 158 </Button> 135 - </div> 159 + </Card> 136 160 137 - <div className="bg-white border border-zinc-200 p-6"> 138 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 161 + <Card> 162 + <Text as="h2" size="xl" className="font-semibold mb-4 block"> 139 163 📖 API Documentation 140 - </h2> 141 - <p className="text-zinc-600 mb-4"> 164 + </Text> 165 + <Text as="p" variant="secondary" className="mb-4"> 142 166 Interactive OpenAPI documentation for your slice's XRPC endpoints. 143 - </p> 167 + </Text> 144 168 <Button 145 169 href={buildSliceUrlFromView(slice, sliceId, "api-docs")} 146 - variant="indigo" 170 + variant="primary" 147 171 > 148 172 View API Docs 149 173 </Button> 150 - </div> 174 + </Card> 151 175 152 176 {hasSliceAccess && ( 153 - <div className="bg-white border border-zinc-200 p-6"> 154 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 177 + <Card> 178 + <Text as="h2" size="xl" className="font-semibold mb-4 block"> 155 179 🔄 Sync 156 - </h2> 157 - <p className="text-zinc-600 mb-4"> 180 + </Text> 181 + <Text as="p" variant="secondary" className="mb-4"> 158 182 Sync entire collections from AT Protocol network. 159 - </p> 183 + </Text> 160 184 <Button 161 185 href={buildSliceUrlFromView(slice, sliceId, "sync")} 162 186 variant="success" 163 187 > 164 188 Start Sync 165 189 </Button> 166 - </div> 190 + </Card> 167 191 )} 168 192 169 - {collections.length > 0 170 - ? ( 171 - <div className="bg-white border border-zinc-200 p-6"> 172 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 173 - 📊 Synced Collections 174 - </h2> 175 - <p className="text-zinc-600 mb-4"> 176 - Collections currently indexed in the database. 177 - </p> 178 - <div className="space-y-3 max-h-40 overflow-y-auto"> 179 - {collections.map((collection) => ( 180 - <div 181 - key={collection.name} 182 - className="border-b border-zinc-100 pb-2" 193 + {collections.length > 0 && ( 194 + <Card> 195 + <Text as="h2" size="xl" className="font-semibold mb-4 block"> 196 + 📊 Synced Collections 197 + </Text> 198 + <Text as="p" variant="secondary" className="mb-4"> 199 + Collections currently indexed in the database. 200 + </Text> 201 + <div className="space-y-3 max-h-40 overflow-y-auto"> 202 + {collections.map((collection) => ( 203 + <div 204 + key={collection.name} 205 + className="border-b border-zinc-100 dark:border-zinc-800 pb-2" 206 + > 207 + <Link 208 + href={`${buildSliceUrlFromView( 209 + slice, 210 + sliceId, 211 + "records" 212 + )}?collection=${collection.name}`} 213 + variant="muted" 214 + className="text-sm font-medium" 183 215 > 184 - <a 185 - href={`${ 186 - buildSliceUrlFromView( 187 - slice, 188 - sliceId, 189 - "records", 190 - ) 191 - }?collection=${collection.name}`} 192 - className="text-zinc-700 hover:text-zinc-900 hover:underline text-sm font-medium" 193 - > 194 - {collection.name} 195 - </a> 196 - <div className="flex justify-between text-xs text-zinc-500 mt-1"> 197 - <span>{collection.count} records</span> 198 - {collection.actors && ( 199 - <span>{collection.actors} actors</span> 200 - )} 201 - </div> 216 + {collection.name} 217 + </Link> 218 + <div className="flex justify-between mt-1"> 219 + <Text variant="muted" size="xs"> 220 + {formatNumber(collection.count)} records 221 + </Text> 222 + {collection.actors && ( 223 + <Text variant="muted" size="xs"> 224 + {formatNumber(collection.actors)} actors 225 + </Text> 226 + )} 202 227 </div> 203 - ))} 204 - </div> 228 + </div> 229 + ))} 205 230 </div> 206 - ) 207 - : ( 208 - <div className="bg-white border border-zinc-200 p-6"> 209 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 210 - 🌟 Get Started 211 - </h2> 212 - <p className="text-zinc-600 mb-4"> 213 - No records indexed yet. Start by syncing some AT Protocol 214 - collections! 215 - </p> 216 - <div className="space-y-2 text-sm"> 217 - <p className="text-zinc-500">Try syncing collections like:</p> 218 - <code className="block bg-zinc-100 p-2 rounded text-xs"> 219 - app.bsky.feed.post 220 - </code> 221 - <code className="block bg-zinc-100 p-2 rounded text-xs"> 222 - app.bsky.actor.profile 223 - </code> 224 - </div> 225 - </div> 226 - )} 231 + </Card> 232 + )} 227 233 </div> 228 234 </SlicePage> 229 235 );
+49 -3
frontend/src/features/slices/records/handlers.tsx
··· 9 9 } from "../../../routes/slice-middleware.ts"; 10 10 import { extractSliceParams } from "../../../utils/slice-params.ts"; 11 11 import type { IndexedRecord } from "../../../client.ts"; 12 + import { RecordsList } from "./templates/fragments/RecordsList.tsx"; 13 + import { Card } from "../../../shared/fragments/Card.tsx"; 14 + import { EmptyState } from "../../../shared/fragments/EmptyState.tsx"; 15 + import { Button } from "../../../shared/fragments/Button.tsx"; 16 + import { Database } from "lucide-preact"; 12 17 13 18 async function handleSliceRecordsPage( 14 19 req: Request, ··· 47 52 // Fetch real records if a collection is selected 48 53 let records: Array<IndexedRecord & { pretty_value: string }> = []; 49 54 50 - if ((selectedCollection || searchQuery) && collections.length > 0) { 55 + if ((selectedCollection || (searchQuery && searchQuery.trim() !== "")) && collections.length > 0) { 51 56 try { 52 57 const sliceClient = getSliceClient(authContext, sliceParams.sliceId, context.sliceContext.profileDid); 53 58 const recordsResult = await sliceClient.network.slices.slice ··· 56 61 ...(selectedCollection && { 57 62 collection: { eq: selectedCollection }, 58 63 }), 59 - ...(searchQuery && { json: { contains: searchQuery } }), 64 + ...(searchQuery && searchQuery.trim() !== "" && { json: { contains: searchQuery } }), 60 65 ...(selectedAuthor && { did: { eq: selectedAuthor } }), 61 66 }, 62 67 limit: 20, ··· 78 83 } 79 84 } 80 85 86 + // Check if this is an HTMX request for just the records container 87 + const isHtmxRequest = req.headers.get("hx-request") === "true"; 88 + 89 + if (isHtmxRequest) { 90 + // Return just the records container content for HTMX updates 91 + return renderHTML( 92 + <div> 93 + {records.length > 0 ? ( 94 + <RecordsList records={records} /> 95 + ) : ( 96 + <Card> 97 + <EmptyState 98 + icon={<Database size={64} strokeWidth={1} />} 99 + title={ 100 + selectedCollection || searchQuery 101 + ? "No records found" 102 + : "No records to display" 103 + } 104 + description={ 105 + selectedCollection || searchQuery 106 + ? "Try adjusting your filters or search terms." 107 + : context.sliceContext?.hasAccess 108 + ? "Start by syncing some AT Protocol collections." 109 + : "This slice hasn't indexed any records yet." 110 + } 111 + withPadding 112 + > 113 + {context.sliceContext?.hasAccess && ( 114 + <Button 115 + href={`/profile/${sliceParams.handle}/slice/${sliceParams.sliceId}/sync`} 116 + variant="primary" 117 + > 118 + Go to Sync 119 + </Button> 120 + )} 121 + </EmptyState> 122 + </Card> 123 + )} 124 + </div> 125 + ); 126 + } 127 + 81 128 return renderHTML( 82 129 <SliceRecordsPage 83 130 slice={context.sliceContext!.slice!} 84 131 sliceId={sliceParams.sliceId} 85 132 records={records} 86 133 collection={selectedCollection} 87 - author={selectedAuthor} 88 134 search={searchQuery} 89 135 availableCollections={collections} 90 136 currentUser={authContext.currentUser}
+25 -19
frontend/src/features/slices/records/templates/SliceRecordsPage.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 { Card } from "../../../../shared/fragments/Card.tsx"; 4 5 import { RecordFilterForm } from "./fragments/RecordFilterForm.tsx"; 5 6 import { RecordsList } from "./fragments/RecordsList.tsx"; 6 - import { FileText } from "lucide-preact"; 7 + import { Database } from "lucide-preact"; 7 8 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 8 9 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 9 - import type { 10 - IndexedRecord, 11 - NetworkSlicesSliceDefsSliceView, 12 - } from "../../../../client.ts"; 10 + import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 11 + import { IndexedRecord } from "@slices/client"; 13 12 14 13 interface Record extends IndexedRecord { 15 14 pretty_value?: string; ··· 26 25 records?: Record[]; 27 26 availableCollections?: AvailableCollection[]; 28 27 collection?: string; 29 - author?: string; 30 28 search?: string; 31 29 currentUser?: AuthenticatedUser; 32 30 hasSliceAccess?: boolean; ··· 38 36 records = [], 39 37 availableCollections = [], 40 38 collection = "", 41 - author = "", 42 39 search = "", 43 40 currentUser, 44 41 hasSliceAccess, ··· 55 52 <RecordFilterForm 56 53 availableCollections={availableCollections} 57 54 collection={collection} 58 - author={author} 59 55 search={search} 56 + sliceId={sliceId} 57 + slice={slice} 60 58 /> 61 59 62 - {records.length > 0 63 - ? <RecordsList records={records} /> 64 - : ( 65 - <div className="bg-white border border-zinc-200"> 60 + <div id="records-container"> 61 + {records.length > 0 ? ( 62 + <RecordsList records={records} /> 63 + ) : ( 64 + <Card> 66 65 <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 66 + icon={<Database size={64} strokeWidth={1} />} 67 + title={ 68 + collection || search 69 + ? "No records found" 70 + : "No records to display" 71 + } 72 + description={ 73 + collection || search 74 + ? "Try adjusting your filters or search terms." 75 + : hasSliceAccess 72 76 ? "Start by syncing some AT Protocol collections." 73 - : "This slice hasn't indexed any records yet."} 77 + : "This slice hasn't indexed any records yet." 78 + } 74 79 withPadding 75 80 > 76 81 {hasSliceAccess && ( ··· 82 87 </Button> 83 88 )} 84 89 </EmptyState> 85 - </div> 90 + </Card> 86 91 )} 92 + </div> 87 93 </SlicePage> 88 94 ); 89 95 }
+44 -53
frontend/src/features/slices/records/templates/fragments/RecordFilterForm.tsx
··· 1 - import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 1 import { Input } from "../../../../../shared/fragments/Input.tsx"; 3 2 import { Select } from "../../../../../shared/fragments/Select.tsx"; 3 + import { buildSliceUrlFromView } from "../../../../../utils/slice-params.ts"; 4 + import type { NetworkSlicesSliceDefsSliceView } from "../../../../../client.ts"; 4 5 5 6 interface AvailableCollection { 6 7 name: string; ··· 10 11 interface RecordFilterFormProps { 11 12 availableCollections: AvailableCollection[]; 12 13 collection: string; 13 - author: string; 14 14 search: string; 15 + sliceId: string; 16 + slice: NetworkSlicesSliceDefsSliceView; 15 17 } 16 18 17 19 export function RecordFilterForm({ 18 20 availableCollections, 19 21 collection, 20 - author, 21 22 search, 23 + sliceId, 24 + slice, 22 25 }: RecordFilterFormProps) { 26 + const recordsUrl = buildSliceUrlFromView(slice, sliceId, "records"); 23 27 return ( 24 - <div className="bg-white border border-zinc-200 p-6 mb-6"> 25 - <div className="flex justify-between items-center mb-4"> 26 - <h2 className="text-xl font-semibold text-zinc-900">Filter Records</h2> 27 - </div> 28 - <form 29 - className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" 30 - method="get" 31 - _="on submit 32 - if #author.value is empty 33 - remove @name from #author 34 - end 35 - if #search.value is empty 36 - remove @name from #search 37 - end" 38 - > 39 - <Select label="Collection" name="collection"> 40 - <option value="">All Collections</option> 41 - {availableCollections.map((coll) => ( 42 - <option 43 - key={coll.name} 44 - value={coll.name} 45 - selected={coll.name === collection} 46 - > 47 - {coll.name} ({coll.count}) 48 - </option> 49 - ))} 50 - </Select> 28 + <div className="mb-6"> 29 + <div className="flex gap-4"> 30 + <div className="flex-1"> 31 + <Input 32 + type="text" 33 + name="search" 34 + value={search} 35 + placeholder="Search record content, can include URIs and DIDs" 36 + hx-get={recordsUrl} 37 + hx-trigger="input changed delay:300ms, search" 38 + hx-target="#records-container" 39 + hx-swap="innerHTML" 40 + hx-include="[name='collection']" 41 + /> 42 + </div> 51 43 52 - <Input 53 - label="Author DID" 54 - type="text" 55 - name="author" 56 - id="author" 57 - value={author} 58 - placeholder="did:plc:..." 59 - /> 60 - 61 - <Input 62 - label="Search" 63 - type="text" 64 - name="search" 65 - id="search" 66 - value={search} 67 - placeholder="Search in record content..." 68 - /> 69 - 70 - <div className="flex items-end"> 71 - <Button type="submit" variant="primary"> 72 - {search ? "Search" : "Filter"} 73 - </Button> 44 + <div className="flex-shrink-0 w-64"> 45 + <Select 46 + name="collection" 47 + hx-get={recordsUrl} 48 + hx-trigger="change" 49 + hx-target="#records-container" 50 + hx-swap="innerHTML" 51 + hx-include="[name='search']" 52 + > 53 + <option value="">All Collections</option> 54 + {availableCollections.map((coll) => ( 55 + <option 56 + key={coll.name} 57 + value={coll.name} 58 + selected={coll.name === collection} 59 + > 60 + {coll.name} ({coll.count}) 61 + </option> 62 + ))} 63 + </Select> 74 64 </div> 75 - </form> 65 + 66 + </div> 76 67 </div> 77 68 ); 78 69 }
+33 -33
frontend/src/features/slices/records/templates/fragments/RecordsList.tsx
··· 1 1 import type { IndexedRecord } from "../../../../../client.ts"; 2 + import { Card } from "../../../../../shared/fragments/Card.tsx"; 3 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 2 4 3 5 interface Record extends IndexedRecord { 4 6 pretty_value?: string; ··· 10 12 11 13 export function RecordsList({ records }: RecordsListProps) { 12 14 return ( 13 - <div className="bg-white border border-zinc-200"> 14 - <div className="px-6 py-4 border-b border-zinc-200"> 15 - <h2 className="text-lg font-semibold text-zinc-900"> 16 - Records ({records.length}) 17 - </h2> 18 - </div> 19 - <div className="divide-y divide-zinc-200"> 15 + <Card padding="none"> 16 + <Card.Header 17 + title={`Records (${records.length})`} 18 + /> 19 + <Card.Content className="divide-y divide-zinc-200 dark:divide-zinc-700"> 20 20 {records.map((record) => ( 21 21 <div key={record.uri} className="p-6"> 22 22 <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> 23 23 <div> 24 - <h3 className="text-lg font-medium text-zinc-900 mb-2"> 24 + <Text as="h3" size="lg" className="font-medium mb-2"> 25 25 Metadata 26 - </h3> 26 + </Text> 27 27 <dl className="grid grid-cols-1 gap-x-4 gap-y-2 text-sm"> 28 28 <div className="grid grid-cols-3 gap-4"> 29 - <dt className="font-medium text-zinc-500">URI:</dt> 30 - <dd className="col-span-2 text-zinc-900 break-all"> 29 + <Text as="dt" size="sm" variant="muted" className="font-medium">URI:</Text> 30 + <Text as="dd" size="sm" className="col-span-2 break-all"> 31 31 {record.uri} 32 - </dd> 32 + </Text> 33 33 </div> 34 34 <div className="grid grid-cols-3 gap-4"> 35 - <dt className="font-medium text-zinc-500"> 35 + <Text as="dt" size="sm" variant="muted" className="font-medium"> 36 36 Collection: 37 - </dt> 38 - <dd className="col-span-2 text-zinc-900"> 37 + </Text> 38 + <Text as="dd" size="sm" className="col-span-2"> 39 39 {record.collection} 40 - </dd> 40 + </Text> 41 41 </div> 42 42 <div className="grid grid-cols-3 gap-4"> 43 - <dt className="font-medium text-zinc-500">DID:</dt> 44 - <dd className="col-span-2 text-zinc-900 break-all"> 43 + <Text as="dt" size="sm" variant="muted" className="font-medium">DID:</Text> 44 + <Text as="dd" size="sm" className="col-span-2 break-all"> 45 45 {record.did} 46 - </dd> 46 + </Text> 47 47 </div> 48 48 <div className="grid grid-cols-3 gap-4"> 49 - <dt className="font-medium text-zinc-500">CID:</dt> 50 - <dd className="col-span-2 text-zinc-900 break-all"> 49 + <Text as="dt" size="sm" variant="muted" className="font-medium">CID:</Text> 50 + <Text as="dd" size="sm" className="col-span-2 break-all"> 51 51 {record.cid} 52 - </dd> 52 + </Text> 53 53 </div> 54 54 <div className="grid grid-cols-3 gap-4"> 55 - <dt className="font-medium text-zinc-500"> 55 + <Text as="dt" size="sm" variant="muted" className="font-medium"> 56 56 Indexed: 57 - </dt> 58 - <dd className="col-span-2 text-zinc-900"> 57 + </Text> 58 + <Text as="dd" size="sm" className="col-span-2"> 59 59 {new Date(record.indexedAt).toLocaleString()} 60 - </dd> 60 + </Text> 61 61 </div> 62 62 </dl> 63 63 </div> 64 64 <div> 65 - <h3 className="text-lg font-medium text-zinc-900 mb-2"> 65 + <Text as="h3" size="lg" className="font-medium mb-2"> 66 66 Record Data 67 - </h3> 68 - <pre className="bg-zinc-50 border border-zinc-200 p-3 text-xs overflow-auto max-h-64"> 69 - {record.pretty_value || 70 - JSON.stringify(record.value, null, 2)} 67 + </Text> 68 + <pre className="bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 p-3 text-xs overflow-auto max-h-64"> 69 + <Text as="span" size="xs">{record.pretty_value || 70 + JSON.stringify(record.value, null, 2)}</Text> 71 71 </pre> 72 72 </div> 73 73 </div> 74 74 </div> 75 75 ))} 76 - </div> 77 - </div> 76 + </Card.Content> 77 + </Card> 78 78 ); 79 79 }
+34 -27
frontend/src/features/slices/settings/templates/SliceSettings.tsx
··· 3 3 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 4 4 import { Button } from "../../../../shared/fragments/Button.tsx"; 5 5 import { Input } from "../../../../shared/fragments/Input.tsx"; 6 + import { Card } from "../../../../shared/fragments/Card.tsx"; 7 + import { Text } from "../../../../shared/fragments/Text.tsx"; 8 + import { FlashMessage } from "../../../../shared/fragments/FlashMessage.tsx"; 6 9 7 10 interface SliceSettingsProps { 8 11 slice: NetworkSlicesSliceDefsSliceView; ··· 32 35 > 33 36 {/* Success Message */} 34 37 {updated && ( 35 - <div className="bg-green-50 border border-green-200 px-4 py-3 mb-4"> 36 - ✅ Slice settings updated successfully! 37 - </div> 38 + <FlashMessage 39 + type="success" 40 + message="Slice settings updated successfully!" 41 + /> 38 42 )} 39 43 40 44 {/* Error Message */} 41 45 {error && ( 42 - <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 mb-4"> 43 - ❌ {error === "update_failed" 44 - ? "Failed to update slice settings. Please try again." 45 - : "An error occurred."} 46 - </div> 46 + <FlashMessage 47 + type="error" 48 + message={ 49 + error === "update_failed" 50 + ? "Failed to update slice settings. Please try again." 51 + : "An error occurred." 52 + } 53 + /> 47 54 )} 48 55 49 56 {/* Settings Content */} 50 57 <div className="space-y-8"> 51 58 {/* Edit Slice Settings */} 52 - <div className="bg-white border border-zinc-200 p-6"> 53 - <h2 className="text-xl font-semibold text-zinc-900 mb-4"> 59 + <Card> 60 + <Text as="h2" size="xl" className="font-semibold mb-4"> 54 61 Edit Slice Settings 55 - </h2> 56 - <p className="text-zinc-600 mb-4"> 62 + </Text> 63 + <Text as="p" variant="secondary" className="mb-4"> 57 64 Update your slice name and primary domain. 58 - </p> 65 + </Text> 59 66 <form 60 67 hx-put={`/api/slices/${sliceId}/settings`} 61 68 hx-target="#settings-form-result" ··· 82 89 required 83 90 placeholder="e.g. social.grain" 84 91 /> 85 - <p className="mt-1 text-xs text-zinc-500"> 92 + <Text as="p" size="xs" variant="muted" className="mt-1"> 86 93 Primary namespace for this slice's collections 87 - </p> 94 + </Text> 88 95 </div> 89 96 90 97 <div className="flex justify-start"> 91 - <Button 92 - type="submit" 93 - variant="primary" 94 - size="lg" 95 - > 98 + <Button type="submit" variant="success" size="lg"> 96 99 Update Settings 97 100 </Button> 98 101 </div> 99 102 <div id="settings-form-result" className="mt-4"></div> 100 103 </form> 101 - </div> 104 + </Card> 102 105 103 106 {/* Danger Zone */} 104 - <div className="bg-white border border-zinc-200 p-6 border-l-4 border-l-red-500"> 105 - <h2 className="text-xl font-semibold text-red-800 mb-4"> 107 + <Card className="border-l-4 border-l-red-500"> 108 + <Text 109 + as="h2" 110 + size="xl" 111 + className="font-semibold mb-4 text-red-800 dark:text-red-400" 112 + > 106 113 Danger Zone 107 - </h2> 108 - <p className="text-zinc-600 mb-4"> 114 + </Text> 115 + <Text as="p" variant="secondary" className="mb-4"> 109 116 Permanently delete this slice and all associated data. This action 110 117 cannot be undone. 111 - </p> 118 + </Text> 112 119 <Button 113 120 type="button" 114 121 hx-delete={`/api/slices/${sliceId}`} ··· 120 127 > 121 128 Delete Slice 122 129 </Button> 123 - </div> 130 + </Card> 124 131 </div> 125 132 </SlicePage> 126 133 );
+5 -10
frontend/src/features/slices/shared/fragments/SliceLogPage.tsx
··· 1 + import type { JSX } from "preact"; 1 2 import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 3 import { Breadcrumb } from "../../../../shared/fragments/Breadcrumb.tsx"; 3 4 import { PageHeader } from "../../../../shared/fragments/PageHeader.tsx"; ··· 10 11 sliceId: string; 11 12 currentUser?: AuthenticatedUser; 12 13 title: string; 13 - headerActions?: preact.ComponentChildren; 14 - breadcrumbHref?: string; 15 - breadcrumbLabel?: string; 14 + headerActions?: JSX.Element | JSX.Element[]; 15 + breadcrumbItems?: Array<{ label: string; href?: string }>; 16 16 children: preact.ComponentChildren; 17 17 } 18 18 ··· 22 22 currentUser, 23 23 title, 24 24 headerActions, 25 - breadcrumbHref, 26 - breadcrumbLabel, 25 + breadcrumbItems, 27 26 children, 28 27 }: SliceLogPageProps) { 29 - const defaultBreadcrumbHref = buildSliceUrlFromView(slice, sliceId); 30 - const defaultBreadcrumbLabel = `Back to ${slice.name}`; 31 - 32 28 return ( 33 29 <Layout title={title} currentUser={currentUser}> 34 30 <div className="px-4 py-8"> 35 31 <Breadcrumb 36 - href={breadcrumbHref || defaultBreadcrumbHref} 37 - label={breadcrumbLabel || defaultBreadcrumbLabel} 32 + items={breadcrumbItems || [{ label: `Back to ${slice.name}`, href: buildSliceUrlFromView(slice, sliceId) }]} 38 33 /> 39 34 <PageHeader title={title}> 40 35 {headerActions}
+12 -13
frontend/src/features/slices/shared/fragments/SlicePage.tsx
··· 13 13 hasSliceAccess?: boolean; 14 14 title?: string; 15 15 headerActions?: preact.ComponentChildren; 16 - breadcrumbHref?: string; 17 - breadcrumbLabel?: string; 18 16 children: preact.ComponentChildren; 19 17 } 20 18 ··· 26 24 hasSliceAccess, 27 25 title, 28 26 headerActions, 29 - breadcrumbHref, 30 - breadcrumbLabel, 31 27 children, 32 28 }: SlicePageProps) { 33 29 const pageTitle = title || slice.name; 34 - const defaultBreadcrumbHref = slice.creator?.handle 35 - ? `/profile/${slice.creator.handle}` 36 - : "/"; 37 - const defaultBreadcrumbLabel = "Back to Profile"; 30 + 31 + // Build breadcrumb items for {userhandle}/{slice name} pattern 32 + const breadcrumbItems = [ 33 + { 34 + label: slice.creator?.handle || "unknown", 35 + href: slice.creator?.handle ? `/profile/${slice.creator.handle}` : undefined, 36 + }, 37 + { 38 + label: slice.name, 39 + }, 40 + ]; 38 41 39 42 return ( 40 43 <Layout title={pageTitle} currentUser={currentUser}> 41 44 <div className="px-4 py-8"> 42 - <Breadcrumb 43 - href={breadcrumbHref || defaultBreadcrumbHref} 44 - label={breadcrumbLabel || defaultBreadcrumbLabel} 45 - /> 45 + <Breadcrumb items={breadcrumbItems} /> 46 46 <PageHeader title={slice.name}> 47 47 {headerActions} 48 48 </PageHeader> ··· 52 52 slice={slice} 53 53 sliceId={sliceId} 54 54 currentTab={currentTab} 55 - currentUser={currentUser} 56 55 hasSliceAccess={hasSliceAccess} 57 56 /> 58 57 )}
+15 -14
frontend/src/features/slices/shared/fragments/SliceTabs.tsx
··· 11 11 export function getSliceTabs( 12 12 slice: NetworkSlicesSliceDefsSliceView, 13 13 sliceId: string, 14 - currentUser?: AuthenticatedUser, 15 - hasSliceAccess?: boolean, 14 + hasSliceAccess?: boolean 16 15 ): SliceTab[] { 17 16 const tabs = [ 18 17 { ··· 46 45 id: "codegen", 47 46 name: "Code Gen", 48 47 href: buildSliceUrlFromView(slice, sliceId, "codegen"), 49 - }, 48 + } 50 49 ); 51 50 52 51 // Add oauth and settings tabs only if user owns the slice ··· 61 60 id: "settings", 62 61 name: "Settings", 63 62 href: buildSliceUrlFromView(slice, sliceId, "settings"), 64 - }, 63 + } 65 64 ); 66 65 } 67 66 ··· 72 71 slice: NetworkSlicesSliceDefsSliceView; 73 72 sliceId: string; 74 73 currentTab: string; 75 - currentUser?: AuthenticatedUser; 76 74 hasSliceAccess?: boolean; 77 75 } 78 76 79 - export function SliceTabs( 80 - { slice, sliceId, currentTab, currentUser, hasSliceAccess }: SliceTabsProps, 81 - ) { 82 - const tabs = getSliceTabs(slice, sliceId, currentUser, hasSliceAccess); 77 + export function SliceTabs({ 78 + slice, 79 + sliceId, 80 + currentTab, 81 + hasSliceAccess, 82 + }: SliceTabsProps) { 83 + const tabs = getSliceTabs(slice, sliceId, hasSliceAccess); 83 84 84 85 return ( 85 - <nav className="border-b border-gray-200 mb-6"> 86 - <div className="flex space-x-8"> 86 + <nav className="border-b border-zinc-200 dark:border-zinc-800 mb-6"> 87 + <div className="flex space-x-4 sm:space-x-6 md:space-x-8 overflow-x-auto scrollbar-hide"> 87 88 {tabs.map((tab) => ( 88 89 <a 89 90 key={tab.id} 90 91 href={tab.href} 91 - className={`py-2 px-1 border-b-2 font-medium text-sm ${ 92 + className={`py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap flex-shrink-0 ${ 92 93 currentTab === tab.id 93 - ? "border-blue-500 text-blue-600" 94 - : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" 94 + ? "border-blue-500 dark:border-blue-400 text-blue-600 dark:text-blue-400" 95 + : "border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300 hover:border-gray-300 dark:hover:border-zinc-600" 95 96 }`} 96 97 > 97 98 {tab.name}
+5 -3
frontend/src/features/slices/sync-logs/templates/SyncJobLogsPage.tsx
··· 22 22 sliceId={sliceId} 23 23 currentUser={currentUser} 24 24 title="Sync Job Logs" 25 - breadcrumbHref={buildSliceUrlFromView(slice, sliceId, "sync")} 26 - breadcrumbLabel="Back to Sync" 25 + breadcrumbItems={[ 26 + { label: slice.name, href: buildSliceUrlFromView(slice, sliceId) }, 27 + { label: "Sync", href: buildSliceUrlFromView(slice, sliceId, "sync") }, 28 + { label: jobId.split("-")[0] + "..." } 29 + ]} 27 30 headerActions={ 28 31 <div className="text-sm text-zinc-500 font-mono"> 29 32 Job: {jobId} ··· 31 34 } 32 35 > 33 36 <div 34 - className="bg-white border border-zinc-200" 35 37 hx-get={`/api/slices/${sliceId}/sync/${jobId}`} 36 38 hx-trigger="load" 37 39 hx-swap="innerHTML"
+14 -17
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 { Card } from "../../../../shared/fragments/Card.tsx"; 3 4 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 ··· 27 27 hasSliceAccess={hasSliceAccess} 28 28 title={`${slice.name} - Sync`} 29 29 > 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 - Recent Sync History 34 - </h2> 30 + <div> 31 + <div className="flex justify-end mb-4"> 35 32 <Button 36 33 variant="success" 37 34 hx-get={`/api/slices/${sliceId}/sync/modal`} 38 35 hx-target="#modal-container" 39 36 hx-swap="innerHTML" 40 37 > 41 - <span className="flex items-center gap-2"> 42 - <RefreshCw size={16} /> 43 - Start Sync 44 - </span> 38 + <span className="flex items-center gap-2">Start Sync</span> 45 39 </Button> 46 40 </div> 47 - <div 48 - hx-get={`/api/slices/${sliceId}/job-history?handle=${slice.creator?.handle}`} 49 - hx-trigger="load, every 10s" 50 - hx-swap="innerHTML" 51 - > 52 - <JobHistory jobs={[]} sliceId={sliceId} /> 53 - </div> 41 + <Card padding="none"> 42 + <Card.Header title="Recent Sync History" /> 43 + <Card.Content 44 + hx-get={`/api/slices/${sliceId}/job-history?handle=${slice.creator?.handle}`} 45 + hx-trigger="load, every 10s" 46 + hx-swap="innerHTML" 47 + > 48 + <JobHistory jobs={[]} sliceId={sliceId} /> 49 + </Card.Content> 50 + </Card> 54 51 </div> 55 52 56 53 <div id="modal-container"></div>
+68 -70
frontend/src/features/slices/sync/templates/fragments/JobHistory.tsx
··· 1 - import { ChevronRight, Clock } from "lucide-preact"; 1 + import { Clock, Timer, CircleCheck, XCircle } from "lucide-preact"; 2 2 import { EmptyState } from "../../../../../shared/fragments/EmptyState.tsx"; 3 + import { ListItem } from "../../../../../shared/fragments/ListItem.tsx"; 4 + import { Link } from "../../../../../shared/fragments/Link.tsx"; 5 + import { Text } from "../../../../../shared/fragments/Text.tsx"; 6 + import { timeAgo } from "../../../../../utils/time.ts"; 3 7 import { buildSliceUrl } from "../../../../../utils/slice-params.ts"; 4 8 5 9 interface JobResult { ··· 23 27 jobs: JobHistoryItem[]; 24 28 sliceId: string; 25 29 handle?: string; 26 - } 27 - 28 - function formatDate(dateString: string): string { 29 - const date = new Date(dateString); 30 - return ( 31 - date.toLocaleDateString() + 32 - " " + 33 - date.toLocaleTimeString([], { 34 - hour: "2-digit", 35 - minute: "2-digit", 36 - }) 37 - ); 38 30 } 39 31 40 32 function extractDurationFromMessage(message: string): string { ··· 45 37 const numValue = parseFloat(value); 46 38 47 39 if (unit === "ms") { 48 - if (numValue < 1000) return `${Math.round(numValue)}ms`; 49 - return `${(numValue / 1000).toFixed(1)}s`; 40 + if (numValue < 1000) return "1s"; 41 + return `${Math.round(numValue / 1000)}s`; 50 42 } else if (unit === "s") { 51 - if (numValue < 60) return `${numValue}s`; 43 + if (numValue < 60) return `${Math.round(numValue)}s`; 52 44 const minutes = Math.floor(numValue / 60); 53 45 const seconds = Math.round(numValue % 60); 54 46 return `${minutes}m ${seconds}s`; 55 47 } else if (unit === "m") { 56 - return `${numValue}m`; 48 + return `${Math.round(numValue)}m`; 57 49 } 58 50 59 51 return `${value}${unit}`; ··· 72 64 } 73 65 74 66 return ( 75 - <div className="divide-y divide-zinc-200"> 67 + <div> 76 68 {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 - <span className="text-green-600 font-medium"> 96 - ✅ Success 97 - </span> 98 - ) 99 - : ( 100 - <span className="text-red-600 font-medium"> 101 - ❌ Failed 102 - </span> 103 - )} 104 - {job.result?.message && ( 105 - <span className="text-zinc-400 text-xs"> 106 - ({extractDurationFromMessage(job.result.message)}) 107 - </span> 108 - )} 69 + <ListItem key={job.jobId}> 70 + <div className="flex items-center justify-between w-full px-6 py-4"> 71 + <div className="flex items-center gap-3"> 72 + {!job.result ? ( 73 + <div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse" /> 74 + ) : job.result.success ? ( 75 + <CircleCheck size={16} style={{ color: "#16a34a" }} /> 76 + ) : ( 77 + <XCircle size={16} style={{ color: "#dc2626" }} /> 78 + )} 79 + <div className="flex-1"> 80 + <div className="flex items-center gap-2"> 81 + <Link 82 + href={ 83 + handle 84 + ? buildSliceUrl(handle, sliceId, `sync/${job.jobId}`) 85 + : `/slices/${sliceId}/sync/${job.jobId}` 86 + } 87 + variant="inherit" 88 + > 89 + <Text 90 + as="span" 91 + size="base" 92 + className="font-medium font-mono" 93 + > 94 + {job.jobId.split("-")[0]}... 95 + </Text> 96 + </Link> 109 97 </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 98 {job.result && ( 116 - <p className="text-xs text-zinc-400 mt-1"> 117 - {job.result.totalRecords} records •{" "} 118 - {job.result.reposProcessed} repos 119 - </p> 99 + <> 100 + <Text size="sm" variant="muted" className="mt-1"> 101 + {job.result.totalRecords} records added •{" "} 102 + {job.result.reposProcessed} repos 103 + </Text> 104 + {job.result.collectionsSynced.length > 0 && ( 105 + <Text size="xs" variant="muted" className="mt-1 block"> 106 + {job.result.collectionsSynced.join(", ")} 107 + </Text> 108 + )} 109 + </> 120 110 )} 121 111 {!job.result && ( 122 - <p className="text-xs text-zinc-400 mt-1"> 112 + <Text size="sm" variant="muted" className="mt-1"> 123 113 Job in progress... 124 - </p> 114 + </Text> 125 115 )} 126 116 </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]}... 130 - </div> 131 - <div className="text-zinc-400"> 132 - <ChevronRight size={20} /> 133 - </div> 117 + </div> 118 + <div className="flex flex-col items-start gap-1 ml-4 w-20"> 119 + <div className="flex items-center gap-1"> 120 + <Clock size={12} className="text-zinc-400" /> 121 + <Text size="sm" variant="muted"> 122 + {timeAgo(job.completedAt || job.createdAt)} 123 + </Text> 134 124 </div> 125 + {job.result?.message && ( 126 + <div className="flex items-center gap-1"> 127 + <Timer size={12} className="text-zinc-400" /> 128 + <Text size="sm" variant="muted"> 129 + {extractDurationFromMessage(job.result.message)} 130 + </Text> 131 + </div> 132 + )} 135 133 </div> 136 - </a> 137 - </div> 134 + </div> 135 + </ListItem> 138 136 ))} 139 137 </div> 140 138 );
+6 -5
frontend/src/features/waitlist/templates/WaitlistPage.tsx
··· 1 1 import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 2 import { WaitlistForm } from "./fragments/WaitlistForm.tsx"; 3 3 import { WaitlistSuccess } from "./fragments/WaitlistSuccess.tsx"; 4 + import { Text } from "../../../shared/fragments/Text.tsx"; 4 5 import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 5 6 6 7 interface WaitlistPageProps { ··· 18 19 }: WaitlistPageProps) { 19 20 return ( 20 21 <Layout title="Join the Waitlist - Slices" currentUser={currentUser}> 21 - <div className="min-h-screen bg-white flex items-center justify-center px-4 py-16"> 22 + <div className="min-h-screen bg-white dark:bg-zinc-900 flex items-center justify-center px-4 py-16"> 22 23 <div className="w-full max-w-md"> 23 24 {success ? ( 24 25 <WaitlistSuccess handle={handle} /> 25 26 ) : ( 26 27 <> 27 28 <div className="text-center mb-8"> 28 - <h1 className="text-4xl font-bold text-zinc-900 mb-4"> 29 + <Text as="h1" size="3xl" className="font-bold mb-4"> 29 30 Join the Slices Waitlist 30 - </h1> 31 - <p className="text-lg text-zinc-600"> 31 + </Text> 32 + <Text as="p" size="lg" variant="secondary"> 32 33 Be among the first to experience the future of AT Protocol ecosystem tools. 33 - </p> 34 + </Text> 34 35 </div> 35 36 <WaitlistForm error={error} /> 36 37 </>
+35 -23
frontend/src/features/waitlist/templates/fragments/WaitlistForm.tsx
··· 1 1 import { Button } from "../../../../shared/fragments/Button.tsx"; 2 2 import { Input } from "../../../../shared/fragments/Input.tsx"; 3 + import { Card } from "../../../../shared/fragments/Card.tsx"; 4 + import { Text } from "../../../../shared/fragments/Text.tsx"; 5 + import { FlashMessage } from "../../../../shared/fragments/FlashMessage.tsx"; 3 6 4 7 interface WaitlistFormProps { 5 8 error?: string; 6 9 } 7 10 8 11 export function WaitlistForm({ error }: WaitlistFormProps) { 12 + const getErrorMessage = (error: string) => { 13 + switch (error) { 14 + case "oauth_not_configured": 15 + return "OAuth is not configured. Please try again later."; 16 + case "invalid_callback": 17 + return "Invalid authorization callback."; 18 + case "no_user_info": 19 + return "Could not retrieve user information."; 20 + case "waitlist_failed": 21 + return "Failed to join waitlist. Please try again."; 22 + default: 23 + return "An error occurred. Please try again."; 24 + } 25 + }; 26 + 9 27 return ( 10 - <div className="bg-white p-8 border border-zinc-200"> 28 + <Card> 11 29 <form action="/auth/waitlist/initiate" method="POST"> 12 30 <div className="space-y-6"> 13 31 {error && ( 14 - <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3"> 15 - {error === "oauth_not_configured" 16 - ? "OAuth is not configured. Please try again later." 17 - : error === "invalid_callback" 18 - ? "Invalid authorization callback." 19 - : error === "no_user_info" 20 - ? "Could not retrieve user information." 21 - : error === "waitlist_failed" 22 - ? "Failed to join waitlist. Please try again." 23 - : "An error occurred. Please try again."} 24 - </div> 32 + <FlashMessage type="error" message={getErrorMessage(error)} /> 25 33 )} 26 34 27 - <Input 28 - label="Your handle" 29 - name="handle" 30 - placeholder="alice.bsky.social" 31 - helpText="Enter your AT Protocol handle to join the waitlist" 32 - required 33 - /> 35 + <div className="space-y-2"> 36 + <Input 37 + label="Your handle" 38 + name="handle" 39 + placeholder="alice.bsky.social" 40 + required 41 + /> 42 + <Text as="p" size="xs" variant="muted"> 43 + Enter your AT Protocol handle to join the waitlist 44 + </Text> 45 + </div> 34 46 35 47 <div className="space-y-4"> 36 - <Button type="submit" variant="primary" class="w-full justify-center"> 48 + <Button type="submit" variant="primary" className="w-full justify-center"> 37 49 Join Waitlist 38 50 </Button> 39 51 40 - <p className="text-xs text-zinc-500 text-center"> 52 + <Text as="p" size="xs" variant="muted" className="text-center"> 41 53 By joining the waitlist, you'll be notified when Slices is ready for you. 42 - </p> 54 + </Text> 43 55 </div> 44 56 </div> 45 57 </form> 46 - </div> 58 + </Card> 47 59 ); 48 60 }
+17 -15
frontend/src/features/waitlist/templates/fragments/WaitlistSuccess.tsx
··· 1 1 import { Button } from "../../../../shared/fragments/Button.tsx"; 2 + import { Card } from "../../../../shared/fragments/Card.tsx"; 3 + import { Text } from "../../../../shared/fragments/Text.tsx"; 4 + import { Link } from "../../../../shared/fragments/Link.tsx"; 2 5 import { Check } from "lucide-preact"; 3 6 4 7 interface WaitlistSuccessProps { ··· 7 10 8 11 export function WaitlistSuccess({ handle }: WaitlistSuccessProps) { 9 12 return ( 10 - <div className="bg-white p-8 border border-zinc-200 text-center"> 13 + <Card className="text-center"> 11 14 <div className="flex justify-center mb-6"> 12 - <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center"> 13 - <Check size={32} className="text-green-600" /> 15 + <div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center"> 16 + <Check size={32} className="text-green-600 dark:text-green-400" /> 14 17 </div> 15 18 </div> 16 19 17 - <h2 className="text-2xl font-bold text-zinc-900 mb-4"> 20 + <Text as="h2" size="2xl" className="font-bold mb-4"> 18 21 You're on the List! 19 - </h2> 22 + </Text> 20 23 21 - <p className="text-zinc-600 mb-6"> 22 - Thanks for joining the waitlist{handle ? <>, <span className="font-bold">{handle}</span></> : ""}! We'll notify you as soon as Slices is ready for you. 23 - </p> 24 + <Text as="p" variant="secondary" className="mb-6"> 25 + Thanks for joining the waitlist{handle ? <>, <Text as="span" className="font-bold">{handle}</Text></> : ""}! We'll notify you as soon as Slices is ready for you. 26 + </Text> 24 27 25 28 <div className="space-y-4"> 26 - <Button href="/" variant="primary" class="w-full justify-center"> 29 + <Button href="/" variant="primary" className="w-full justify-center"> 27 30 Back to Home 28 31 </Button> 29 32 30 - <p className="text-sm text-zinc-500"> 33 + <Text as="p" size="sm" variant="muted"> 31 34 In the meantime, follow us{" "} 32 - <a 35 + <Link 33 36 href="https://bsky.app/profile/slices.network" 34 37 target="_blank" 35 38 rel="noopener noreferrer" 36 - className="text-blue-500 hover:text-blue-600 underline" 37 39 > 38 40 @slices.network 39 - </a>{" "} 41 + </Link>{" "} 40 42 for updates and sneak peeks. 41 - </p> 43 + </Text> 42 44 </div> 43 - </div> 45 + </Card> 44 46 ); 45 47 }
+4 -4
frontend/src/routes/mod.ts
··· 31 31 // Documentation routes 32 32 ...docsRoutes, 33 33 34 - // Dashboard routes (home page, create slice) 35 - ...dashboardRoutes, 36 - 37 34 // User settings routes 38 35 ...settingsRoutes, 39 36 40 - // Slice-specific routes 37 + // Slice-specific routes (must come before dashboard routes to avoid conflicts) 41 38 ...overviewRoutes, 42 39 ...sliceSettingsRoutes, 43 40 ...lexiconRoutes, ··· 48 45 ...syncRoutes, 49 46 ...syncLogsRoutes, 50 47 ...jetstreamRoutes, 48 + 49 + // Dashboard routes (home page, create slice) 50 + ...dashboardRoutes, 51 51 ];
+3 -2
frontend/src/shared/fragments/ActivitySparkline.tsx
··· 1 1 import type { NetworkSlicesSliceDefsSparklinePoint } from "../../client.ts"; 2 + import { Text } from "./Text.tsx"; 2 3 3 4 interface ActivitySparklineProps { 4 5 sparklineData?: NetworkSlicesSliceDefsSparklinePoint[]; ··· 96 97 </svg> 97 98 98 99 {/* Activity label */} 99 - <div className="ml-2 text-xs text-zinc-500"> 100 + <Text size="xs" variant="muted" className="ml-2"> 100 101 {dataPoints.reduce((sum, count) => sum + count, 0)} records 101 - </div> 102 + </Text> 102 103 </div> 103 104 ); 104 105 }
+7 -6
frontend/src/shared/fragments/AvatarInput.tsx
··· 1 1 import { ActorAvatar } from "./ActorAvatar.tsx"; 2 + import { Text } from "./Text.tsx"; 2 3 3 4 interface AvatarInputProps { 4 5 profile?: { ··· 12 13 export function AvatarInput({ profile }: AvatarInputProps) { 13 14 return ( 14 15 <div> 15 - <label className="block text-sm font-medium text-zinc-700 mb-2"> 16 + <Text as="label" size="sm" variant="label" className="block font-medium mb-2"> 16 17 Avatar 17 - </label> 18 + </Text> 18 19 <label htmlFor="avatar" className="cursor-pointer"> 19 - <div className="border rounded-full border-zinc-300 w-16 h-16 mb-2 relative hover:border-zinc-400 transition-colors"> 20 - <div className="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 20 + <div className="border rounded-full border-zinc-300 dark:border-zinc-600 w-16 h-16 mb-2 relative hover:border-zinc-400 dark:hover:border-zinc-500 transition-colors"> 21 + <div className="absolute bottom-0 right-0 bg-zinc-800 dark:bg-zinc-700 rounded-full w-5 h-5 flex items-center justify-center z-10"> 21 22 <svg 22 23 className="w-3 h-3 text-white" 23 24 fill="currentColor" ··· 43 44 /> 44 45 ) 45 46 : ( 46 - <div className="w-full h-full bg-zinc-100 flex items-center justify-center"> 47 + <div className="w-full h-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center"> 47 48 <svg 48 - className="w-8 h-8 text-zinc-400" 49 + className="w-8 h-8 text-zinc-400 dark:text-zinc-500" 49 50 fill="currentColor" 50 51 viewBox="0 0 20 20" 51 52 >
+33
frontend/src/shared/fragments/Badge.tsx
··· 1 + import type { ComponentChildren } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + type BadgeVariant = "primary" | "secondary" | "success" | "warning" | "danger" | "info"; 5 + 6 + interface BadgeProps { 7 + variant?: BadgeVariant; 8 + children: ComponentChildren; 9 + className?: string; 10 + } 11 + 12 + const badgeVariants = { 13 + primary: "bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300", 14 + secondary: "bg-zinc-100 dark:bg-zinc-800 text-zinc-800 dark:text-zinc-300", 15 + success: "bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300", 16 + warning: "bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300", 17 + danger: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300", 18 + info: "bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300", 19 + }; 20 + 21 + export function Badge({ variant = "secondary", children, className }: BadgeProps) { 22 + return ( 23 + <span 24 + className={cn( 25 + "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium", 26 + badgeVariants[variant], 27 + className 28 + )} 29 + > 30 + {children} 31 + </span> 32 + ); 33 + }
+43 -11
frontend/src/shared/fragments/Breadcrumb.tsx
··· 1 1 import { ChevronLeft } from "lucide-preact"; 2 + import { Text } from "./Text.tsx"; 3 + import { Link } from "./Link.tsx"; 4 + 5 + interface BreadcrumbItem { 6 + label: string; 7 + href?: string; 8 + } 2 9 3 10 interface BreadcrumbProps { 4 - href: string; 11 + items: BreadcrumbItem[]; 12 + // Legacy support 13 + href?: string; 5 14 label?: string; 6 15 } 7 16 8 - export function Breadcrumb( 9 - { href, label = "Back to Slices" }: BreadcrumbProps, 10 - ) { 17 + export function Breadcrumb({ items, href, label }: BreadcrumbProps) { 18 + // Legacy mode - use old behavior 19 + if (href && label) { 20 + return ( 21 + <div className="mb-2"> 22 + <a 23 + href={href} 24 + className="inline-flex items-center text-sm text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 transition-colors" 25 + > 26 + <ChevronLeft size={16} className="mr-1" /> 27 + <span>{label}</span> 28 + </a> 29 + </div> 30 + ); 31 + } 32 + 33 + // New path-style breadcrumb 11 34 return ( 12 35 <div className="mb-2"> 13 - <a 14 - href={href} 15 - className="inline-flex items-center text-sm text-zinc-500 hover:text-zinc-700 transition-colors" 16 - > 17 - <ChevronLeft size={16} className="mr-1" /> 18 - <span>{label}</span> 19 - </a> 36 + <div className="flex items-center gap-1 text-sm"> 37 + {items.map((item, index) => ( 38 + <div key={index} className="flex items-center gap-1"> 39 + {index > 0 && <Text variant="muted">/</Text>} 40 + {item.href ? ( 41 + <Link href={item.href} variant="muted"> 42 + {item.label} 43 + </Link> 44 + ) : ( 45 + <Text variant="primary" className="font-medium"> 46 + {item.label} 47 + </Text> 48 + )} 49 + </div> 50 + ))} 51 + </div> 20 52 </div> 21 53 ); 22 54 }
+21 -25
frontend/src/shared/fragments/Button.tsx
··· 2 2 import { cn } from "../../utils/cn.ts"; 3 3 4 4 type ButtonVariant = 5 - | "primary" // blue 6 - | "secondary" // gray 7 - | "danger" // red 5 + | "primary" // zinc-900 background 6 + | "secondary" // zinc-200/800 background 7 + | "outline" // transparent with border 8 8 | "success" // green 9 - | "warning" // orange 10 - | "purple" // purple 11 - | "indigo" // indigo 12 - | "ghost"; // transparent with hover 9 + | "danger" // red 10 + | "blue"; // blue 13 11 14 12 type ButtonSize = "sm" | "md" | "lg"; 15 13 ··· 22 20 } 23 21 24 22 const variantClasses = { 25 - primary: "bg-blue-500 hover:bg-blue-600 text-white", 26 - secondary: "bg-gray-500 hover:bg-gray-600 text-white", 27 - danger: "bg-red-600 hover:bg-red-700 text-white", 28 - success: "bg-green-500 hover:bg-green-600 text-white", 29 - warning: "bg-orange-500 hover:bg-orange-600 text-white", 30 - purple: "bg-purple-500 hover:bg-purple-600 text-white", 31 - indigo: "bg-indigo-500 hover:bg-indigo-600 text-white", 32 - ghost: "bg-transparent hover:bg-gray-100 text-current", 23 + primary: "bg-zinc-50 hover:bg-zinc-50/90 dark:bg-zinc-600 dark:hover:bg-zinc-600/90 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-500/70", 24 + secondary: "bg-zinc-100 hover:bg-zinc-100/90 dark:bg-zinc-700 dark:hover:bg-zinc-700/90 text-zinc-900 dark:text-zinc-100 border border-zinc-200/70 dark:border-zinc-600/70", 25 + outline: "bg-transparent border border-zinc-300/70 hover:bg-zinc-50 dark:border-zinc-600/70 dark:hover:bg-zinc-600/10 text-zinc-900 dark:text-zinc-100", 26 + success: "bg-green-600 hover:bg-green-600/90 dark:bg-green-600 dark:hover:bg-green-600/90 text-white border border-green-700 dark:border-green-500", 27 + danger: "bg-red-600 hover:bg-red-600/90 dark:bg-red-600 dark:hover:bg-red-600/90 text-white border border-red-700 dark:border-red-500", 28 + blue: "bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border border-blue-700 dark:border-blue-500", 33 29 }; 34 30 35 31 const sizeClasses = { 36 - sm: "px-3 py-1 text-sm", 37 - md: "px-4 py-2", 38 - lg: "px-6 py-2 font-medium", 32 + sm: "px-2.5 py-0.5 text-xs", 33 + md: "px-3 py-1 text-sm", 34 + lg: "px-4 py-2 text-sm font-medium", 39 35 }; 40 36 41 37 export function Button(props: ButtonProps): JSX.Element { 42 38 const { 43 39 variant = "primary", 44 - size = "md", 40 + size = "lg", 45 41 children, 46 42 href, 47 - class: classProp, 43 + className, 48 44 ...rest 49 45 } = props; 50 46 51 - const className = cn( 52 - "rounded transition-colors inline-flex items-center", 47 + const classes = cn( 48 + "rounded transition-colors inline-flex items-center font-medium", 53 49 variantClasses[variant], 54 50 sizeClasses[size], 55 - classProp, 51 + className, 56 52 ); 57 53 58 54 if (href) { 59 55 return ( 60 - <a href={href} class={className}> 56 + <a href={href} class={classes}> 61 57 {children} 62 58 </a> 63 59 ); 64 60 } 65 61 66 62 return ( 67 - <button class={className} {...rest}> 63 + <button class={classes} {...rest}> 68 64 {children} 69 65 </button> 70 66 );
+77
frontend/src/shared/fragments/Card.tsx
··· 1 + import type { JSX, ComponentChildren } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + import { Text } from "./Text.tsx"; 4 + 5 + type CardVariant = "default" | "hover" | "danger"; 6 + 7 + interface CardProps { 8 + variant?: CardVariant; 9 + padding?: "none" | "sm" | "md" | "lg"; 10 + className?: string; 11 + children: JSX.Element | JSX.Element[]; 12 + } 13 + 14 + interface CardHeaderProps { 15 + title: string; 16 + action?: JSX.Element; 17 + className?: string; 18 + } 19 + 20 + interface CardContentProps extends JSX.HTMLAttributes<HTMLDivElement> { 21 + children: ComponentChildren; 22 + className?: string; 23 + } 24 + 25 + const variantClasses = { 26 + default: "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm", 27 + hover: "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 hover:shadow-sm transition-all rounded-sm", 28 + danger: "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 border-l-4 border-l-red-500 rounded-sm", 29 + }; 30 + 31 + const paddingClasses = { 32 + none: "", 33 + sm: "p-4", 34 + md: "p-6", 35 + lg: "p-8", 36 + }; 37 + 38 + export function Card({ 39 + variant = "default", 40 + padding = "md", 41 + className, 42 + children, 43 + ...props 44 + }: CardProps & JSX.HTMLAttributes<HTMLDivElement>): JSX.Element { 45 + const classes = cn( 46 + variantClasses[variant], 47 + paddingClasses[padding], 48 + className 49 + ); 50 + 51 + return ( 52 + <div className={classes} {...props}> 53 + {children} 54 + </div> 55 + ); 56 + } 57 + 58 + Card.Header = function CardHeader({ title, action, className }: CardHeaderProps): JSX.Element { 59 + return ( 60 + <div className={cn("bg-zinc-50 dark:bg-zinc-800 px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 rounded-t-sm", className)}> 61 + <div className="flex items-center justify-between"> 62 + <Text as="h2" size="base" className="font-semibold"> 63 + {title} 64 + </Text> 65 + {action} 66 + </div> 67 + </div> 68 + ); 69 + }; 70 + 71 + Card.Content = function CardContent({ children, className, ...props }: CardContentProps): JSX.Element { 72 + return ( 73 + <div className={cn("bg-white dark:bg-zinc-900", className)} {...props}> 74 + {children} 75 + </div> 76 + ); 77 + };
+7 -6
frontend/src/shared/fragments/EmptyState.tsx
··· 1 1 import type { ComponentChildren } from "preact"; 2 + import { Text } from "./Text.tsx"; 2 3 3 4 interface EmptyStateProps { 4 5 icon: ComponentChildren; ··· 16 17 children, 17 18 }: EmptyStateProps) { 18 19 const content = ( 19 - <div className="bg-zinc-50 border border-zinc-200 p-6 text-center"> 20 - <div className="text-zinc-400 mb-4 flex justify-center"> 20 + <div className="p-6 text-center"> 21 + <div className="text-zinc-400 dark:text-zinc-500 mb-4 flex justify-center"> 21 22 {icon} 22 23 </div> 23 - <h3 className="text-lg font-medium text-zinc-900 mb-2"> 24 + <Text as="h3" size="lg" className="font-medium mb-2"> 24 25 {title} 25 - </h3> 26 - <p className="text-zinc-500 mb-6"> 26 + </Text> 27 + <Text as="p" variant="muted" className="mb-6"> 27 28 {description} 28 - </p> 29 + </Text> 29 30 {children} 30 31 </div> 31 32 );
+30 -8
frontend/src/shared/fragments/FlashMessage.tsx
··· 1 + import { Card } from "./Card.tsx"; 2 + import { Text } from "./Text.tsx"; 3 + import { CheckCircle2, XCircle } from "lucide-preact"; 4 + 1 5 interface FlashMessageProps { 2 6 type: "success" | "error"; 3 7 message: string; ··· 7 11 export function FlashMessage( 8 12 { type, message, className = "" }: FlashMessageProps, 9 13 ) { 10 - const baseClasses = "px-4 py-3 mb-4 border"; 11 - const typeClasses = type === "success" 12 - ? "bg-green-50 border-green-200 text-green-700" 13 - : "bg-red-50 border-red-200 text-red-700"; 14 - const icon = type === "success" ? "✅" : "❌"; 14 + if (type === "success") { 15 + return ( 16 + <Card padding="sm" className={`mb-4 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 ${className}`}> 17 + <div className="flex items-center gap-2"> 18 + <CheckCircle2 19 + size={16} 20 + style={{ fill: '#16a34a', stroke: 'white', strokeWidth: 1 }} 21 + /> 22 + <Text variant="success"> 23 + {message} 24 + </Text> 25 + </div> 26 + </Card> 27 + ); 28 + } 15 29 16 30 return ( 17 - <div className={`${baseClasses} ${typeClasses} ${className}`}> 18 - {icon} {message} 19 - </div> 31 + <Card padding="sm" className={`mb-4 bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 ${className}`}> 32 + <div className="flex items-center gap-2"> 33 + <XCircle 34 + size={16} 35 + style={{ fill: '#dc2626', stroke: 'white', strokeWidth: 1 }} 36 + /> 37 + <Text variant="error"> 38 + {message} 39 + </Text> 40 + </div> 41 + </Card> 20 42 ); 21 43 }
+18 -6
frontend/src/shared/fragments/Input.tsx
··· 1 1 import type { JSX } from "preact"; 2 2 import { cn } from "../../utils/cn.ts"; 3 3 4 + type InputSize = "sm" | "md" | "lg"; 5 + 4 6 export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> { 5 7 label?: string; 6 8 error?: string; 9 + size?: InputSize; 7 10 } 8 11 9 12 export function Input(props: InputProps): JSX.Element { 10 - const { class: classProp, label, error, ...rest } = props; 13 + const { class: classProp, label, error, size = "lg", ...rest } = props; 14 + 15 + const sizeClasses = { 16 + sm: "px-2.5 py-0.5 text-xs", 17 + md: "px-3 py-1 text-sm", 18 + lg: "px-4 py-2 text-sm", 19 + }; 20 + 11 21 const className = cn( 12 - "block w-full border border-zinc-300 rounded-md px-3 py-2", 22 + "block w-full border border-zinc-200 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-500 dark:placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:border-transparent", 23 + sizeClasses[size], 13 24 error 14 - ? "border-red-300 focus:border-red-500 focus:ring-red-500" 15 - : "focus:border-zinc-500 focus:ring-zinc-500", 25 + ? "border-red-500 dark:border-red-400 focus:ring-red-500 dark:focus:ring-red-400" 26 + : "focus:ring-blue-500 dark:focus:ring-blue-400", 27 + props.disabled && "bg-zinc-50 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 cursor-not-allowed", 16 28 classProp, 17 29 ); 18 30 19 31 return ( 20 32 <div> 21 33 {label && ( 22 - <label className="block text-sm font-medium text-zinc-700 mb-2"> 34 + <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2"> 23 35 {label} 24 36 {props.required && <span className="text-red-500 ml-1">*</span>} 25 37 </label> 26 38 )} 27 39 <input class={className} {...rest} /> 28 - {error && <p className="mt-1 text-sm text-red-600">{error}</p>} 40 + {error && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>} 29 41 </div> 30 42 ); 31 43 }
+19 -117
frontend/src/shared/fragments/Layout.tsx
··· 1 1 import { JSX } from "preact"; 2 2 import type { AuthenticatedUser } from "../../routes/middleware.ts"; 3 + import { Navigation } from "./Navigation.tsx"; 4 + import { cn } from "../../utils/cn.ts"; 3 5 4 6 interface LayoutProps { 5 7 title?: string; ··· 27 29 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 28 30 <title>{title}</title> 29 31 <meta name="description" content={description} /> 32 + 33 + {/* Favicon - Letter S */} 34 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' fill='black'/><text x='50' y='70' font-size='60' font-family='system-ui' fill='white' text-anchor='middle' font-weight='bold'>S</text></svg>" /> 30 35 31 36 {/* Open Graph / Facebook */} 32 37 <meta property="og:type" content="website" /> ··· 56 61 .htmx-request .default-text { 57 62 display: none; 58 63 } 64 + 65 + /* Shiki dual theme support */ 66 + @media (prefers-color-scheme: dark) { 67 + .shiki, 68 + .shiki span { 69 + color: var(--shiki-dark) !important; 70 + background-color: var(--shiki-dark-bg) !important; 71 + } 72 + } 59 73 `, 60 74 }} 61 75 /> 62 76 </head> 63 - <body className="min-h-screen bg-white"> 64 - {showNavigation && ( 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 - <div className="mx-auto max-w-5xl h-full flex items-center justify-between px-4"> 67 - <div className="flex items-center space-x-4"> 68 - <a 69 - href="/" 70 - className="text-xl font-bold text-zinc-900 hover:text-zinc-700" 71 - > 72 - Slices 73 - </a> 74 - <a 75 - href="/docs" 76 - className="px-3 py-1.5 text-sm text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded-md transition-colors" 77 - > 78 - Docs 79 - </a> 80 - </div> 81 - <div className="flex items-center space-x-2"> 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 96 - on click from document 97 - if not me.contains(event.target) and not #avatar-dropdown.contains(event.target) 98 - add .hidden to #avatar-dropdown" 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> 117 - 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> 152 - </div> 153 - </div> 154 - </div> 155 - </div> 156 - ) 157 - : ( 158 - <div className="flex items-center space-x-2"> 159 - <a 160 - href="/waitlist" 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 - > 163 - Join Waitlist 164 - </a> 165 - <a 166 - href="/login" 167 - className="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors" 168 - > 169 - Sign in 170 - </a> 171 - </div> 172 - )} 173 - </div> 174 - </div> 175 - </nav> 176 - )} 77 + <body className="min-h-screen bg-white dark:bg-zinc-900 dark:text-white"> 78 + {showNavigation && <Navigation currentUser={currentUser} />} 177 79 <div 178 80 className={`min-h-screen flex flex-col ${ 179 - fullWidth ? "" : "max-w-5xl mx-auto sm:border-x border-zinc-200" 81 + fullWidth ? "" : "max-w-5xl mx-auto sm:border-x border-zinc-200 dark:border-zinc-800" 180 82 }`} 181 83 style={backgroundStyle} 182 84 > 183 85 {showNavigation 184 86 ? ( 185 - <main className="flex-1 sm:pt-14"> 87 + <main className={cn("flex-1 sm:pt-14", !backgroundStyle && "bg-white dark:bg-zinc-900")}> 186 88 {children} 187 89 </main> 188 90 ) 189 91 : ( 190 - <main className="flex-1"> 92 + <main className={cn("flex-1", !backgroundStyle && "bg-white dark:bg-zinc-900")}> 191 93 {children} 192 94 </main> 193 95 )}
+42
frontend/src/shared/fragments/Link.tsx
··· 1 + import type { JSX, ComponentChildren } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + type LinkVariant = 5 + | "default" // Standard link styling 6 + | "muted" // Subtle link styling 7 + | "inherit" // Inherit text color from parent 8 + | "button"; // Button-like link styling 9 + 10 + interface LinkProps { 11 + variant?: LinkVariant; 12 + className?: string; 13 + children: ComponentChildren; 14 + href: string; 15 + } 16 + 17 + // Centralized link styles 18 + const linkVariants = { 19 + default: "text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 hover:underline underline-offset-2 decoration-current", 20 + muted: "text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white hover:underline underline-offset-2 decoration-current", 21 + inherit: "hover:underline underline-offset-2 decoration-current", 22 + button: "inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors", 23 + }; 24 + 25 + export function Link({ 26 + variant = "default", 27 + className, 28 + children, 29 + href, 30 + ...props 31 + }: LinkProps & Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, 'children' | 'href'>): JSX.Element { 32 + const classes = cn( 33 + linkVariants[variant], 34 + className 35 + ); 36 + 37 + return ( 38 + <a href={href} className={classes} {...props}> 39 + {children} 40 + </a> 41 + ); 42 + }
+42
frontend/src/shared/fragments/ListItem.tsx
··· 1 + import type { JSX, ComponentChildren } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + interface ListItemProps extends JSX.HTMLAttributes<HTMLDivElement> { 5 + children: ComponentChildren; 6 + href?: string; 7 + className?: string; 8 + onClick?: () => void; 9 + } 10 + 11 + export function ListItem({ children, href, className, onClick, ...props }: ListItemProps): JSX.Element { 12 + const baseClasses = "flex items-center bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors border-b border-zinc-200 dark:border-zinc-700 last:border-b-0"; 13 + 14 + if (href) { 15 + return ( 16 + <a 17 + href={href} 18 + className={cn(baseClasses, "block", className)} 19 + > 20 + {children} 21 + </a> 22 + ); 23 + } 24 + 25 + if (onClick) { 26 + return ( 27 + <button 28 + type="button" 29 + onClick={onClick} 30 + className={cn(baseClasses, "w-full text-left", className)} 31 + > 32 + {children} 33 + </button> 34 + ); 35 + } 36 + 37 + return ( 38 + <div className={cn(baseClasses, className)} {...props}> 39 + {children} 40 + </div> 41 + ); 42 + }
+9 -6
frontend/src/shared/fragments/LogLevelBadge.tsx
··· 1 + import { cn } from "../../utils/cn.ts"; 2 + 1 3 interface LogLevelBadgeProps { 2 4 level: string; 3 5 } 4 6 5 7 export function LogLevelBadge({ level }: LogLevelBadgeProps) { 6 8 const colors: Record<string, string> = { 7 - error: "bg-red-100 text-red-800", 8 - warn: "bg-yellow-100 text-yellow-800", 9 - info: "bg-blue-100 text-blue-800", 10 - debug: "bg-gray-100 text-gray-800", 9 + error: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300", 10 + warn: "bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300", 11 + info: "bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300", 12 + debug: "bg-gray-100 dark:bg-zinc-800 text-gray-800 dark:text-zinc-300", 11 13 }; 12 14 13 15 return ( 14 16 <span 15 - className={`px-2 py-1 rounded text-xs font-medium ${ 17 + className={cn( 18 + "px-2 py-1 rounded text-xs font-medium", 16 19 colors[level] || colors.debug 17 - }`} 20 + )} 18 21 > 19 22 {level.toUpperCase()} 20 23 </span>
+27 -33
frontend/src/shared/fragments/LogViewer.tsx
··· 1 1 import type { LogEntry } from "../../client.ts"; 2 2 import { LogLevelBadge } from "./LogLevelBadge.tsx"; 3 + import { Text } from "./Text.tsx"; 4 + import { Card } from "./Card.tsx"; 3 5 4 6 interface LogViewerProps { 5 7 logs: LogEntry[]; ··· 14 16 }: LogViewerProps) { 15 17 if (logs.length === 0) { 16 18 return ( 17 - <div className="p-8 text-center text-zinc-500"> 18 - {emptyMessage} 19 + <div className="p-8 text-center"> 20 + <Text as="p" variant="muted">{emptyMessage}</Text> 19 21 </div> 20 22 ); 21 23 } ··· 25 27 const infoCount = logs.filter((l) => l.level === "info").length; 26 28 27 29 return ( 28 - <div className="divide-y divide-zinc-200"> 29 - {/* Log Stats Header */} 30 - <div className="p-4 bg-zinc-50"> 31 - <div className="flex gap-4 text-sm"> 32 - <span> 33 - Total logs: <strong>{logs.length}</strong> 34 - </span> 30 + <Card padding="none"> 31 + <div className="bg-zinc-50 dark:bg-zinc-800 px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 rounded-t-sm"> 32 + <div className="flex items-center gap-4"> 33 + <Text as="span" size="sm"> 34 + Total: <strong>{logs.length}</strong> 35 + </Text> 35 36 {errorCount > 0 && ( 36 - <span className="text-red-600"> 37 + <Text as="span" size="sm" variant="error"> 37 38 Errors: <strong>{errorCount}</strong> 38 - </span> 39 + </Text> 39 40 )} 40 41 {warnCount > 0 && ( 41 - <span className="text-yellow-600"> 42 + <Text as="span" size="sm" variant="warning"> 42 43 Warnings: <strong>{warnCount}</strong> 43 - </span> 44 + </Text> 44 45 )} 45 - <span className="text-blue-600"> 46 + <Text as="span" size="sm" className="text-blue-600 dark:text-blue-400"> 46 47 Info: <strong>{infoCount}</strong> 47 - </span> 48 + </Text> 48 49 </div> 49 50 </div> 50 51 51 - {/* Log Entries */} 52 - <div> 52 + <Card.Content className="divide-y divide-zinc-200 dark:divide-zinc-700"> 53 53 {logs.map((log) => ( 54 54 <div 55 55 key={log.id} 56 - className={`p-3 hover:bg-zinc-50 font-mono text-sm ${ 57 - log.level === "error" 58 - ? "bg-red-50" 59 - : log.level === "warn" 60 - ? "bg-yellow-50" 61 - : "" 62 - }`} 56 + className="p-3 hover:bg-zinc-50 dark:hover:bg-zinc-800 font-mono text-sm" 63 57 > 64 58 <div className="flex items-start gap-3"> 65 - <span className="text-zinc-400 text-xs"> 59 + <Text as="span" size="xs" variant="muted"> 66 60 {formatTimestamp(log.createdAt)} 67 - </span> 61 + </Text> 68 62 <LogLevelBadge level={log.level} /> 69 63 <div className="flex-1"> 70 - <div className="text-zinc-800">{log.message}</div> 64 + <Text as="div" size="sm">{log.message}</Text> 71 65 {log.metadata && Object.keys(log.metadata).length > 0 && ( 72 66 <details className="mt-2"> 73 67 <summary 74 - className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-700" 68 + className="cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300" 75 69 _="on click toggle .hidden on next <pre/>" 76 70 > 77 - View metadata 71 + <Text as="span" size="xs" variant="muted">View metadata</Text> 78 72 </summary> 79 - <pre className="mt-2 p-2 bg-zinc-100 rounded text-xs overflow-x-auto hidden"> 80 - {JSON.stringify(log.metadata, null, 2)} 73 + <pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-800 rounded text-xs overflow-x-auto break-words whitespace-pre-wrap hidden"> 74 + <Text as="span" size="xs">{JSON.stringify(log.metadata, null, 2)}</Text> 81 75 </pre> 82 76 </details> 83 77 )} ··· 85 79 </div> 86 80 </div> 87 81 ))} 88 - </div> 89 - </div> 82 + </Card.Content> 83 + </Card> 90 84 ); 91 85 }
+17 -4
frontend/src/shared/fragments/Modal.tsx
··· 1 1 import { ComponentChildren } from "preact"; 2 + import { Text } from "./Text.tsx"; 3 + import { cn } from "../../utils/cn.ts"; 4 + 5 + type ModalSize = "sm" | "md" | "lg" | "xl"; 2 6 3 7 interface ModalProps { 4 8 title: string; 5 9 description?: string; 6 10 children: ComponentChildren; 11 + size?: ModalSize; 7 12 onClose?: string; // Hyperscript for close action, defaults to clearing modal-container 8 13 } 9 14 15 + const sizeClasses = { 16 + sm: "max-w-sm", 17 + md: "max-w-lg", 18 + lg: "max-w-3xl", 19 + xl: "max-w-5xl", 20 + }; 21 + 10 22 export function Modal({ 11 23 title, 12 24 description, 13 25 children, 26 + size = "lg", 14 27 onClose = "on click set #modal-container's innerHTML to ''", 15 28 }: ModalProps) { 16 29 return ( ··· 20 33 onClose.replace("on click ", "") 21 34 }`} 22 35 > 23 - <div className="bg-white rounded-lg p-6 max-w-3xl w-full max-h-[90vh] overflow-y-auto"> 36 + <div className={cn("bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-md p-6 w-full max-h-[90vh] overflow-y-auto", sizeClasses[size])}> 24 37 <div className="flex justify-between items-start mb-4"> 25 38 <div> 26 - <h2 className="text-2xl font-semibold">{title}</h2> 27 - {description && <p className="text-zinc-600 mt-2">{description}</p>} 39 + <Text as="h2" size="2xl" className="font-semibold">{title}</Text> 40 + {description && <Text as="p" variant="secondary" className="mt-2">{description}</Text>} 28 41 </div> 29 42 <button 30 43 type="button" 31 44 _={onClose} 32 - className="text-gray-400 hover:text-gray-600 text-2xl leading-none" 45 + className="text-gray-400 dark:text-zinc-500 hover:text-gray-600 dark:hover:text-zinc-400 text-2xl leading-none" 33 46 > 34 47 35 48 </button>
+123
frontend/src/shared/fragments/Navigation.tsx
··· 1 + import type { AuthenticatedUser } from "../../routes/middleware.ts"; 2 + import { Button } from "./Button.tsx"; 3 + 4 + interface NavigationProps { 5 + currentUser?: AuthenticatedUser; 6 + } 7 + 8 + export function Navigation({ currentUser }: NavigationProps) { 9 + return ( 10 + <nav className="sm:fixed sm:top-0 sm:left-0 sm:right-0 h-14 z-50 bg-zinc-50 dark:bg-zinc-950 border-b border-zinc-200 dark:border-zinc-800"> 11 + <div className="mx-auto max-w-5xl h-full flex items-center justify-between px-4"> 12 + <div className="flex items-center space-x-4"> 13 + <a 14 + href="/" 15 + className="text-xl font-bold text-zinc-900 dark:text-white hover:text-zinc-700 dark:hover:text-zinc-300" 16 + > 17 + Slices 18 + </a> 19 + <a 20 + href="/docs" 21 + className="px-3 py-1.5 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors" 22 + > 23 + Docs 24 + </a> 25 + </div> 26 + <div className="flex items-center space-x-2"> 27 + {currentUser?.isAuthenticated 28 + ? ( 29 + <div className="flex items-center space-x-2"> 30 + <a 31 + href={`/profile/${currentUser.handle}`} 32 + className="px-3 py-1.5 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors" 33 + > 34 + Dashboard 35 + </a> 36 + <div className="relative"> 37 + <button 38 + type="button" 39 + className="flex items-center p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" 40 + _="on click toggle .hidden on #avatar-dropdown 41 + on click from document 42 + if not me.contains(event.target) and not #avatar-dropdown.contains(event.target) 43 + add .hidden to #avatar-dropdown" 44 + > 45 + {currentUser.avatar 46 + ? ( 47 + <img 48 + src={currentUser.avatar} 49 + alt="Profile avatar" 50 + className="w-8 h-8 rounded-full" 51 + /> 52 + ) 53 + : ( 54 + <div className="w-8 h-8 bg-zinc-300 dark:bg-zinc-700 rounded-full flex items-center justify-center"> 55 + <span className="text-sm text-zinc-600 dark:text-zinc-300 font-medium"> 56 + {currentUser.handle?.charAt(0) 57 + .toUpperCase() || "U"} 58 + </span> 59 + </div> 60 + )} 61 + </button> 62 + 63 + <div 64 + id="avatar-dropdown" 65 + className="hidden absolute right-0 mt-2 w-64 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md shadow-lg z-50" 66 + > 67 + <div className="py-1"> 68 + <div className="px-4 py-3 border-b border-zinc-100 dark:border-zinc-800"> 69 + <div className="text-sm font-medium text-zinc-900 dark:text-white"> 70 + {currentUser.displayName || 71 + currentUser.handle || "User"} 72 + </div> 73 + <div className="text-sm text-zinc-500 dark:text-zinc-400"> 74 + {currentUser.handle 75 + ? `@${currentUser.handle}` 76 + : ""} 77 + </div> 78 + </div> 79 + <a 80 + href="/settings" 81 + className="block px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" 82 + > 83 + Settings 84 + </a> 85 + <form 86 + method="post" 87 + action="/logout" 88 + className="block" 89 + > 90 + <button 91 + type="submit" 92 + className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" 93 + > 94 + Sign out 95 + </button> 96 + </form> 97 + </div> 98 + </div> 99 + </div> 100 + </div> 101 + ) 102 + : ( 103 + <div className="flex items-center space-x-2"> 104 + <a 105 + href="/waitlist" 106 + className="px-3 py-1.5 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors" 107 + > 108 + Join Waitlist 109 + </a> 110 + <Button 111 + href="/login" 112 + variant="blue" 113 + size="md" 114 + > 115 + Sign in 116 + </Button> 117 + </div> 118 + )} 119 + </div> 120 + </div> 121 + </nav> 122 + ); 123 + }
+4 -2
frontend/src/shared/fragments/PageHeader.tsx
··· 1 + import type { JSX } from "preact"; 2 + 1 3 interface PageHeaderProps { 2 4 title: string; 3 - children?: preact.ComponentChildren; 5 + children?: JSX.Element | JSX.Element[]; 4 6 } 5 7 6 8 export function PageHeader({ title, children }: PageHeaderProps) { 7 9 return ( 8 10 <div className="flex items-center justify-between mb-8"> 9 - <h1 className="text-3xl font-bold text-zinc-900">{title}</h1> 11 + <h1 className="text-3xl font-bold text-zinc-900 dark:text-white">{title}</h1> 10 12 {children && <div className="flex items-center gap-4">{children}</div>} 11 13 </div> 12 14 );
+32 -10
frontend/src/shared/fragments/Select.tsx
··· 1 1 import type { JSX } from "preact"; 2 2 import { cn } from "../../utils/cn.ts"; 3 + import { Text } from "./Text.tsx"; 4 + import { ChevronDown } from "lucide-preact"; 5 + 6 + type SelectSize = "sm" | "md" | "lg"; 3 7 4 8 export interface SelectProps 5 9 extends JSX.SelectHTMLAttributes<HTMLSelectElement> { 6 10 label?: string; 7 11 error?: string; 12 + size?: SelectSize; 8 13 } 9 14 10 15 export function Select(props: SelectProps): JSX.Element { 11 - const { class: classProp, label, error, children, ...rest } = props; 16 + const { class: classProp, label, error, size = "lg", children, ...rest } = props; 17 + 18 + const sizeClasses = { 19 + sm: "pl-2.5 pr-8 py-0.5 text-xs", 20 + md: "pl-3 pr-8 py-1 text-sm", 21 + lg: "pl-4 pr-9 py-2 text-sm", 22 + }; 23 + 12 24 const className = cn( 13 - "block w-full border border-gray-300 rounded-md px-3 py-2", 25 + "block w-full bg-zinc-50 hover:bg-zinc-50/90 dark:bg-zinc-600 dark:hover:bg-zinc-600/90 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-500/70 rounded-md cursor-pointer focus:outline-none focus:ring-0 appearance-none", 26 + sizeClasses[size], 14 27 error 15 - ? "border-red-300 focus:border-red-500 focus:ring-red-500" 16 - : "focus:border-blue-500 focus:ring-blue-500", 28 + ? "border-red-300 dark:border-red-500" 29 + : "", 17 30 classProp, 18 31 ); 19 32 20 33 return ( 21 34 <div> 22 35 {label && ( 23 - <label className="block text-sm font-medium text-gray-700 mb-2"> 36 + <Text as="label" size="sm" variant="label" className="block mb-2"> 24 37 {label} 25 - </label> 38 + </Text> 39 + )} 40 + <div className="relative"> 41 + <select class={className} {...rest}> 42 + {children} 43 + </select> 44 + <div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> 45 + <ChevronDown size={16} className="text-zinc-500 dark:text-zinc-400" /> 46 + </div> 47 + </div> 48 + {error && ( 49 + <Text as="p" size="xs" variant="error" className="mt-1"> 50 + {error} 51 + </Text> 26 52 )} 27 - <select class={className} {...rest}> 28 - {children} 29 - </select> 30 - {error && <p className="mt-1 text-sm text-red-600">{error}</p>} 31 53 </div> 32 54 ); 33 55 }
+12 -14
frontend/src/shared/fragments/SliceCard.tsx
··· 1 1 import { ActorAvatar } from "./ActorAvatar.tsx"; 2 2 import { ActivitySparkline } from "./ActivitySparkline.tsx"; 3 + import { Text } from "./Text.tsx"; 3 4 import { timeAgo } from "../../utils/time.ts"; 4 5 import { buildSliceUrlFromView } from "../../utils/slice-params.ts"; 5 6 import type { NetworkSlicesSliceDefsSliceView } from "../../client.ts"; ··· 15 16 16 17 return ( 17 18 <a href={sliceUrl} className="block"> 18 - <div className="bg-white border border-zinc-200 rounded-lg p-4 hover:border-zinc-300 hover:shadow-sm transition-all cursor-pointer"> 19 + <div className="bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 hover:border-zinc-300 dark:hover:border-zinc-600 hover:shadow-sm transition-all cursor-pointer"> 19 20 <div className="flex items-start space-x-3"> 20 21 {/* Avatar */} 21 22 <ActorAvatar profile={slice.creator} size={40} /> ··· 25 26 {/* Main content */} 26 27 <div className="flex-1 min-w-0"> 27 28 <div className="flex flex-wrap items-center gap-x-2 gap-y-1 mb-1"> 28 - <span className="text-sm font-medium text-zinc-900 truncate"> 29 + <Text size="sm" className="font-medium truncate"> 29 30 {slice.creator.displayName || slice.creator.handle} 30 - </span> 31 - <span className="text-sm text-zinc-500 truncate"> 31 + </Text> 32 + <Text size="sm" variant="muted" className="truncate"> 32 33 @{slice.creator.handle} 33 - </span> 34 - <time 35 - className="text-sm text-zinc-400" 36 - dateTime={slice.createdAt} 37 - > 34 + </Text> 35 + <Text size="sm" variant="muted"> 38 36 {timeAgo(slice.createdAt)} 39 - </time> 37 + </Text> 40 38 </div> 41 39 <div className="group"> 42 - <h3 className="text-lg font-semibold text-zinc-900 group-hover:text-zinc-700 mb-1 break-words"> 40 + <Text as="h3" size="lg" className="font-semibold mb-1 break-words"> 43 41 {slice.name} 44 - </h3> 45 - <p className="text-sm text-zinc-600 mb-2 break-all">{slice.domain}</p> 42 + </Text> 43 + <Text size="sm" variant="muted" className="mb-2 break-all">{slice.domain}</Text> 46 44 {/* Stats badges */} 47 - <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500"> 45 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400 dark:text-zinc-500 mt-2"> 48 46 {(slice.indexedRecordCount ?? 0) > 0 && ( 49 47 <> 50 48 <span className="flex items-center gap-1 whitespace-nowrap" title="Indexed Records">
+74
frontend/src/shared/fragments/Text.tsx
··· 1 + import type { JSX, ComponentChildren } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + type TextVariant = 5 + | "primary" // Main body text: text-zinc-900 dark:text-white 6 + | "secondary" // Medium text: text-zinc-600 dark:text-zinc-400 7 + | "muted" // Muted text: text-zinc-500 dark:text-zinc-400 8 + | "label" // Form labels: text-zinc-700 dark:text-zinc-300 9 + | "subtle" // Subtle text: text-zinc-400 dark:text-zinc-500 10 + | "error" // Error text: text-red-600 dark:text-red-400 11 + | "warning" // Warning text: text-yellow-600 dark:text-yellow-400 12 + | "success"; // Success text: text-green-600 dark:text-green-400 13 + 14 + type TextSize = "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl"; 15 + 16 + type TextElement = 17 + | "p" 18 + | "span" 19 + | "div" 20 + | "time" 21 + | "label" 22 + | "h1" 23 + | "h2" 24 + | "h3" 25 + | "h4" 26 + | "h5" 27 + | "h6"; 28 + 29 + interface TextProps { 30 + variant?: TextVariant; 31 + size?: TextSize; 32 + as?: TextElement; 33 + className?: string; 34 + children: ComponentChildren; 35 + } 36 + 37 + const textColors = { 38 + primary: "text-zinc-900 dark:text-white", 39 + secondary: "text-zinc-600 dark:text-zinc-400", 40 + muted: "text-zinc-500 dark:text-zinc-400", 41 + label: "text-zinc-700 dark:text-zinc-300", 42 + subtle: "text-zinc-400 dark:text-zinc-500", 43 + error: "text-red-600 dark:text-red-400", 44 + warning: "text-yellow-600 dark:text-yellow-400", 45 + success: "text-green-600 dark:text-green-400", 46 + }; 47 + 48 + const textSizes = { 49 + xs: "text-xs", 50 + sm: "text-sm", 51 + base: "text-base", 52 + lg: "text-lg", 53 + xl: "text-xl", 54 + "2xl": "text-2xl", 55 + "3xl": "text-3xl", 56 + }; 57 + 58 + export function Text({ 59 + variant = "primary", 60 + size = "base", 61 + as = "span", 62 + className, 63 + children, 64 + }: TextProps): JSX.Element { 65 + const Component = as; 66 + 67 + const classes = cn(textColors[variant], textSizes[size], className); 68 + 69 + return ( 70 + <Component className={classes}> 71 + {children} 72 + </Component> 73 + ); 74 + }
+19 -7
frontend/src/shared/fragments/Textarea.tsx
··· 1 1 import type { JSX } from "preact"; 2 2 import { cn } from "../../utils/cn.ts"; 3 3 4 + type TextareaSize = "sm" | "md" | "lg"; 5 + 4 6 export interface TextareaProps 5 7 extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> { 6 8 label?: string; 7 9 error?: string; 10 + size?: TextareaSize; 8 11 } 9 12 10 13 export function Textarea(props: TextareaProps): JSX.Element { 11 - const { class: classProp, label, error, ...rest } = props; 14 + const { class: classProp, label, error, size = "lg", ...rest } = props; 15 + 16 + const sizeClasses = { 17 + sm: "px-2.5 py-0.5 text-xs", 18 + md: "px-3 py-1 text-sm", 19 + lg: "px-4 py-2 text-sm", 20 + }; 21 + 12 22 const className = cn( 13 - "block w-full border border-zinc-300 rounded-md px-3 py-2", 23 + "block w-full border border-zinc-200 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-500 dark:placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:border-transparent", 24 + sizeClasses[size], 14 25 error 15 - ? "border-red-300 focus:border-red-500 focus:ring-red-500" 16 - : "focus:border-zinc-500 focus:ring-zinc-500", 26 + ? "border-red-500 dark:border-red-400 focus:ring-red-500 dark:focus:ring-red-400" 27 + : "focus:ring-blue-500 dark:focus:ring-blue-400", 28 + props.disabled && "bg-zinc-50 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 cursor-not-allowed", 17 29 classProp, 18 30 ); 19 31 20 32 return ( 21 33 <div> 22 34 {label && ( 23 - <label className="block text-sm font-medium text-zinc-700 mb-2"> 35 + <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2"> 24 36 {label} 25 - {props.required && <span className="text-red-500 ml-1">*</span>} 37 + {props.required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>} 26 38 </label> 27 39 )} 28 40 <textarea class={className} {...rest} /> 29 - {error && <p className="mt-1 text-sm text-red-600">{error}</p>} 41 + {error && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>} 30 42 </div> 31 43 ); 32 44 }