Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

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

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 }