Highly ambitious ATProtocol AppView service and sdks

refactor frontend into feature folders, breaking out page components and fragments, also added a landing page

Changed files
+4444 -4115
frontend
src
components
features
lib
pages
routes
shared
utils
+114 -3
frontend/CLAUDE.md
··· 1 - - dont use any 2 - - use htmx and hyperscript when possible 3 - - if jsx param is true don't pass the attribute value 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Development Commands 6 + 7 + ```bash 8 + # Start development server with hot reload 9 + deno task dev 10 + 11 + # Start production server 12 + deno task start 13 + 14 + # Format code 15 + deno fmt 16 + 17 + # Run tests 18 + deno test 19 + ``` 20 + 21 + ## Architecture Overview 22 + 23 + This is a Deno-based web application that serves as the frontend for a "Slices" platform - an AT Protocol record management system. The application follows a feature-based architecture with server-side rendering using Preact and HTMX for interactivity. 24 + 25 + ### Technology Stack 26 + - **Runtime**: Deno with TypeScript 27 + - **Frontend**: Preact with server-side rendering 28 + - **Styling**: Tailwind CSS (via CDN) 29 + - **Interactivity**: HTMX + Hyperscript 30 + - **Routing**: Deno's standard HTTP routing 31 + - **Authentication**: OAuth with AT Protocol integration 32 + - **Database**: SQLite via `@slices/oauth` and `@slices/session` 33 + 34 + ### Core Architecture Patterns 35 + 36 + #### Feature-Based Organization 37 + The codebase is organized by features rather than technical layers: 38 + 39 + ``` 40 + src/ 41 + ├── features/ # Feature modules 42 + │ ├── auth/ # Authentication (login/logout) 43 + │ ├── dashboard/ # Main dashboard (slice management) 44 + │ ├── settings/ # User settings 45 + │ └── slices/ # Slice-specific features 46 + │ ├── overview/ # Slice overview 47 + │ ├── lexicon/ # AT Protocol lexicon management 48 + │ ├── records/ # Record browsing/filtering 49 + │ ├── oauth/ # OAuth client management 50 + │ ├── codegen/ # TypeScript client generation 51 + │ ├── sync/ # Data synchronization 52 + │ ├── jetstream/ # Real-time streaming 53 + │ └── api-docs/ # API documentation 54 + ├── shared/ # Shared UI components 55 + ├── routes/ # Route definitions and middleware 56 + ├── utils/ # Utility functions 57 + └── lib/ # Core libraries 58 + ``` 59 + 60 + #### Handler Pattern 61 + Each feature follows a consistent pattern: 62 + - `handlers.tsx` - Route handlers that return Response objects 63 + - `templates/` - Preact components for rendering 64 + - `templates/fragments/` - Reusable UI components 65 + 66 + #### Authentication & Sessions 67 + - OAuth integration with AT Protocol using `@slices/oauth` 68 + - Session management with `@slices/session` 69 + - Authentication state managed via `withAuth()` middleware 70 + - Automatic token refresh capabilities 71 + 72 + ### Key Components 73 + 74 + #### Route System 75 + - All routes defined in `src/routes/mod.ts` 76 + - Feature routes exported from `src/features/*/handlers.tsx` 77 + - Middleware in `src/routes/middleware.ts` handles auth state 78 + 79 + #### Client Integration 80 + - `src/client.ts` - Generated AT Protocol client for API communication 81 + - `src/config.ts` - Centralized configuration and service setup 82 + - Environment variables required: `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_REDIRECT_URI`, `OAUTH_AIP_BASE_URL`, `API_URL`, `SLICE_URI` 83 + 84 + #### Rendering System 85 + - `src/utils/render.tsx` - Unified HTML rendering with proper headers 86 + - Server-side rendering with Preact 87 + - HTMX for dynamic interactions without page reloads 88 + - Shared `Layout` component in `src/shared/fragments/Layout.tsx` 89 + 90 + ### Development Guidelines 91 + 92 + #### Component Conventions 93 + - Use `.tsx` extension for components with JSX 94 + - Preact components for all UI rendering 95 + - HTMX attributes for interactive behavior 96 + - Tailwind classes for styling 97 + 98 + #### Feature Development 99 + When adding new features: 100 + 1. Create feature directory under `src/features/` 101 + 2. Add `handlers.tsx` with route definitions 102 + 3. Create `templates/` directory with Preact components 103 + 4. Export routes from feature and add to `src/routes/mod.ts` 104 + 5. Follow existing authentication patterns using `withAuth()` 105 + 106 + #### Environment Setup 107 + The application requires a `.env` file with OAuth and API configuration. Missing environment variables will cause startup failures with descriptive error messages. 108 + 109 + ### Request/Response Flow 110 + 1. Request hits main server in `src/main.ts` 111 + 2. Routes processed through `src/routes/mod.ts` 112 + 3. Authentication middleware applies session state 113 + 4. Feature handlers process requests and return rendered HTML 114 + 5. HTMX handles partial page updates on client-side interactions
+3 -1
frontend/deno.json
··· 24 24 "preact": "npm:preact@^10.27.1", 25 25 "preact-render-to-string": "npm:preact-render-to-string@^6.5.13", 26 26 "typed-htmx": "npm:typed-htmx@^0.3.1", 27 - "@std/http": "jsr:@std/http@^1.0.20" 27 + "@std/http": "jsr:@std/http@^1.0.20", 28 + "clsx": "npm:clsx@^2.1.1", 29 + "tailwind-merge": "npm:tailwind-merge@^2.5.5" 28 30 }, 29 31 "nodeModulesDir": "auto" 30 32 }
+12
frontend/deno.lock
··· 20 20 "npm:@shikijs/engine-oniguruma@^3.7.0": "3.11.0", 21 21 "npm:@shikijs/types@^3.7.0": "3.11.0", 22 22 "npm:@types/node@*": "22.15.15", 23 + "npm:clsx@^2.1.1": "2.1.1", 23 24 "npm:pg@^8.11.0": "8.16.3", 24 25 "npm:pg@^8.16.3": "8.16.3", 25 26 "npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1", 26 27 "npm:preact@^10.27.1": "10.27.1", 27 28 "npm:shiki@^3.7.0": "3.11.0", 29 + "npm:tailwind-merge@^2.5.5": "2.6.0", 28 30 "npm:typed-htmx@*": "0.3.1", 29 31 "npm:typed-htmx@~0.3.1": "0.3.1" 30 32 }, ··· 34 36 "dependencies": [ 35 37 "npm:@shikijs/core", 36 38 "npm:@shikijs/engine-oniguruma", 39 + "npm:@shikijs/types", 37 40 "npm:shiki" 38 41 ] 39 42 }, ··· 43 46 "@slices/session@0.1.0": { 44 47 "integrity": "63a4e35d70dcb2bb58e6117fdccf308f4a86cd9d94cf99412a3de9d35862cabc", 45 48 "dependencies": [ 49 + "jsr:@slices/oauth", 46 50 "npm:pg@^8.16.3" 47 51 ] 48 52 }, ··· 180 184 "character-entities-legacy@3.0.0": { 181 185 "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==" 182 186 }, 187 + "clsx@2.1.1": { 188 + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" 189 + }, 183 190 "comma-separated-tokens@2.0.3": { 184 191 "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==" 185 192 }, ··· 381 388 "character-entities-legacy" 382 389 ] 383 390 }, 391 + "tailwind-merge@2.6.0": { 392 + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==" 393 + }, 384 394 "trim-lines@3.0.1": { 385 395 "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==" 386 396 }, ··· 521 531 "jsr:@slices/session@0.1", 522 532 "jsr:@std/assert@^1.0.14", 523 533 "jsr:@std/http@^1.0.20", 534 + "npm:clsx@^2.1.1", 524 535 "npm:preact-render-to-string@^6.5.13", 525 536 "npm:preact@^10.27.1", 537 + "npm:tailwind-merge@^2.5.5", 526 538 "npm:typed-htmx@~0.3.1" 527 539 ] 528 540 }
+11 -15
frontend/src/components/CodegenForm.tsx 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 + 1 4 interface CodegenFormProps { 2 5 sliceId: string; 3 6 } ··· 19 22 hx-swap="innerHTML" 20 23 className="space-y-4" 21 24 > 22 - <div> 23 - <label className="block text-sm font-medium text-gray-700 mb-2"> 24 - Output Format 25 - </label> 26 - <select 27 - name="format" 28 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 29 - > 30 - <option value="typescript">TypeScript</option> 31 - </select> 32 - </div> 25 + <Select label="Output Format" name="format"> 26 + <option value="typescript">TypeScript</option> 27 + </Select> 33 28 34 - <button 29 + <Button 35 30 type="submit" 36 - className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-md flex items-center justify-center" 31 + variant="warning" 32 + class="flex items-center justify-center" 37 33 > 38 34 <i 39 35 data-lucide="loader-2" ··· 42 38 ></i> 43 39 <span className="htmx-indicator">Generating Client...</span> 44 40 <span className="default-text">Generate Client</span> 45 - </button> 41 + </Button> 46 42 </form> 47 43 48 44 <div id="generationResult" className="mt-4"> ··· 50 46 </div> 51 47 </div> 52 48 ); 53 - } 49 + }
+5 -4
frontend/src/components/CodegenResult.tsx frontend/src/features/slices/codegen/templates/fragments/CodegenResult.tsx
··· 1 1 import { codeToHtml } from "jsr:@shikijs/shiki"; 2 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 3 3 4 interface CodegenResultProps { 4 5 success: boolean; ··· 23 24 <h3 className="text-lg font-semibold text-green-800"> 24 25 ✅ Generated TypeScript Client 25 26 </h3> 26 - <button 27 - type="button" 28 - className="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm" 27 + <Button 28 + variant="success" 29 + size="sm" 29 30 _="on click js(me) navigator.clipboard.writeText(me.closest('.bg-green-50').querySelector('pre').textContent).then(() => { me.textContent = 'Copied!'; setTimeout(() => me.textContent = 'Copy to Clipboard', 2000); }) end" 30 31 > 31 32 Copy to Clipboard 32 - </button> 33 + </Button> 33 34 </div> 34 35 <div className="rounded overflow-hidden"> 35 36 <div
+21 -35
frontend/src/components/CreateSliceDialog.tsx frontend/src/features/dashboard/templates/fragments/CreateSliceDialog.tsx
··· 1 + import { Input } from "../../../../shared/fragments/Input.tsx"; 2 + import { Button } from "../../../../shared/fragments/Button.tsx"; 3 + 1 4 interface CreateSliceDialogProps { 2 5 error?: string; 3 6 name?: string; ··· 54 57 hx-swap="outerHTML" 55 58 className="space-y-4" 56 59 > 57 - <div> 58 - <label 59 - htmlFor="name" 60 - className="block text-sm font-medium text-gray-700 mb-1" 61 - > 62 - Slice Name 63 - </label> 64 - <input 65 - type="text" 66 - id="name" 67 - name="name" 68 - value={name} 69 - required 70 - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 71 - placeholder="Enter slice name" 72 - /> 73 - </div> 60 + <Input 61 + type="text" 62 + id="name" 63 + name="name" 64 + label="Slice Name" 65 + required 66 + defaultValue={name} 67 + placeholder="Enter slice name" 68 + /> 74 69 75 70 <div> 76 - <label 77 - htmlFor="domain" 78 - className="block text-sm font-medium text-gray-700 mb-1" 79 - > 80 - Primary Domain 81 - </label> 82 - <input 71 + <Input 83 72 type="text" 84 73 id="domain" 85 74 name="domain" 86 - value={domain} 75 + label="Primary Domain" 87 76 required 88 - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 77 + defaultValue={domain} 89 78 placeholder="e.g. social.grain" 90 79 /> 91 80 <p className="mt-1 text-xs text-gray-500"> ··· 94 83 </div> 95 84 96 85 <div className="flex justify-end space-x-3 pt-4"> 97 - <button 86 + <Button 98 87 type="button" 99 - className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-md" 88 + variant="secondary" 100 89 _="on click remove #create-slice-modal" 101 90 > 102 91 Cancel 103 - </button> 104 - <button 105 - type="submit" 106 - className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md" 107 - > 92 + </Button> 93 + <Button type="submit" variant="primary"> 108 94 Create Slice 109 - </button> 95 + </Button> 110 96 </div> 111 97 </form> 112 98 </div> 113 99 </div> 114 100 </div> 115 101 ); 116 - } 102 + }
+1 -1
frontend/src/components/EmptyLexiconState.tsx frontend/src/features/slices/lexicon/templates/fragments/EmptyLexiconState.tsx
··· 36 36 } 37 37 38 38 return content; 39 - } 39 + }
+11 -24
frontend/src/components/JetstreamLogs.tsx frontend/src/shared/fragments/LogViewer.tsx
··· 1 - import type { LogEntry } from "../client.ts"; 2 - import { formatTimestamp } from "../utils/time.ts"; 1 + import type { LogEntry } from "../../client.ts"; 2 + import { LogLevelBadge } from "./LogLevelBadge.tsx"; 3 3 4 - interface JetstreamLogsProps { 4 + interface LogViewerProps { 5 5 logs: LogEntry[]; 6 + emptyMessage?: string; 7 + formatTimestamp?: (timestamp: string) => string; 6 8 } 7 9 8 - function LogLevelBadge({ level }: { level: string }) { 9 - const colors: Record<string, string> = { 10 - error: "bg-red-100 text-red-800", 11 - warn: "bg-yellow-100 text-yellow-800", 12 - info: "bg-blue-100 text-blue-800", 13 - debug: "bg-gray-100 text-gray-800", 14 - }; 15 - 16 - return ( 17 - <span 18 - className={`px-2 py-1 rounded text-xs font-medium ${ 19 - colors[level] || colors.debug 20 - }`} 21 - > 22 - {level.toUpperCase()} 23 - </span> 24 - ); 25 - } 26 - 27 - export function JetstreamLogs({ logs }: JetstreamLogsProps) { 10 + export function LogViewer({ 11 + logs, 12 + emptyMessage = "No logs available.", 13 + formatTimestamp = (timestamp) => new Date(timestamp).toLocaleString() 14 + }: LogViewerProps) { 28 15 if (logs.length === 0) { 29 16 return ( 30 17 <div className="p-8 text-center text-gray-500"> 31 - No Jetstream logs available for this slice. 18 + {emptyMessage} 32 19 </div> 33 20 ); 34 21 }
+13 -7
frontend/src/components/JetstreamStatus.tsx frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatus.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + 1 3 interface JetstreamStatusProps { 2 4 connected: boolean; 3 5 status: string; ··· 29 31 </div> 30 32 <div className="flex items-center gap-3"> 31 33 {sliceId && ( 32 - <a 34 + <Button 33 35 href={`/slices/${sliceId}/jetstream/logs`} 34 - className="bg-green-600 hover:bg-green-700 text-white text-xs px-3 py-1.5 rounded-md transition-colors whitespace-nowrap" 36 + variant="success" 37 + size="sm" 38 + className="whitespace-nowrap" 35 39 > 36 40 View Logs 37 - </a> 41 + </Button> 38 42 )} 39 43 </div> 40 44 </div> ··· 60 64 </div> 61 65 <div className="flex items-center gap-3"> 62 66 {sliceId && ( 63 - <a 67 + <Button 64 68 href={`/slices/${sliceId}/jetstream/logs`} 65 - className="bg-red-600 hover:bg-red-700 text-white text-xs px-3 py-1.5 rounded-md transition-colors whitespace-nowrap" 69 + variant="danger" 70 + size="sm" 71 + className="whitespace-nowrap" 66 72 > 67 73 View Logs 68 - </a> 74 + </Button> 69 75 )} 70 76 </div> 71 77 </div> 72 78 </div> 73 79 ); 74 80 } 75 - } 81 + }
frontend/src/components/JetstreamStatusCompact.tsx frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatusCompact.tsx
+25 -16
frontend/src/components/JobHistory.tsx frontend/src/features/slices/sync/templates/fragments/JobHistory.tsx
··· 22 22 23 23 function formatDate(dateString: string): string { 24 24 const date = new Date(dateString); 25 - return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { 26 - hour: '2-digit', 27 - minute: '2-digit' 28 - }); 25 + return ( 26 + date.toLocaleDateString() + 27 + " " + 28 + date.toLocaleTimeString([], { 29 + hour: "2-digit", 30 + minute: "2-digit", 31 + }) 32 + ); 29 33 } 30 34 31 35 function extractDurationFromMessage(message: string): string { 32 - // Extract duration from message like "Sync completed successfully in 424.233625ms" 33 36 const match = message.match(/in ([\d.]+)(ms|s|m)/); 34 - if (!match) return 'N/A'; 35 - 37 + if (!match) return "N/A"; 38 + 36 39 const [, value, unit] = match; 37 40 const numValue = parseFloat(value); 38 - 39 - if (unit === 'ms') { 41 + 42 + if (unit === "ms") { 40 43 if (numValue < 1000) return `${Math.round(numValue)}ms`; 41 44 return `${(numValue / 1000).toFixed(1)}s`; 42 - } else if (unit === 's') { 45 + } else if (unit === "s") { 43 46 if (numValue < 60) return `${numValue}s`; 44 47 const minutes = Math.floor(numValue / 60); 45 48 const seconds = Math.round(numValue % 60); 46 49 return `${minutes}m ${seconds}s`; 47 - } else if (unit === 'm') { 50 + } else if (unit === "m") { 48 51 return `${numValue}m`; 49 52 } 50 - 53 + 51 54 return `${value}${unit}`; 52 55 } 53 56 ··· 76 79 <div className="flex-1"> 77 80 <div className="flex items-center gap-2 mb-2"> 78 81 {job.result?.success ? ( 79 - <span className="text-green-600 font-medium">✅ Success</span> 82 + <span className="text-green-600 font-medium"> 83 + ✅ Success 84 + </span> 80 85 ) : ( 81 86 <span className="text-red-600 font-medium">❌ Failed</span> 82 87 )} ··· 97 102 <strong>{job.result.totalRecords}</strong> records 98 103 </span> 99 104 <span> 100 - <strong>{job.result.reposProcessed}</strong> repositories 105 + <strong>{job.result.reposProcessed}</strong>{" "} 106 + repositories 101 107 </span> 102 108 <span> 103 - Collections: <strong>{job.result.collectionsSynced.join(', ')}</strong> 109 + Collections:{" "} 110 + <strong> 111 + {job.result.collectionsSynced.join(", ")} 112 + </strong> 104 113 </span> 105 114 </div> 106 115 {job.result.message && ( ··· 120 129 121 130 <div className="flex flex-col items-end gap-2"> 122 131 <div className="text-xs text-gray-400 font-mono"> 123 - {job.jobId.split('-')[0]}... 132 + {job.jobId.split("-")[0]}... 124 133 </div> 125 134 <a 126 135 href={`/slices/${sliceId}/sync/logs/${job.jobId}`}
-96
frontend/src/components/Layout.tsx
··· 1 - import { JSX } from "preact"; 2 - 3 - interface LayoutProps { 4 - title?: string; 5 - children: JSX.Element | JSX.Element[]; 6 - currentUser?: { handle?: string; isAuthenticated: boolean; avatar?: string }; 7 - } 8 - 9 - export function Layout({ 10 - title = "Slices", 11 - children, 12 - currentUser, 13 - }: LayoutProps) { 14 - return ( 15 - <html lang="en"> 16 - <head> 17 - <meta charSet="UTF-8" /> 18 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 19 - <title>{title}</title> 20 - <script src="https://unpkg.com/htmx.org@1.9.10"></script> 21 - <script src="https://unpkg.com/hyperscript.org@0.9.12"></script> 22 - <script src="https://cdn.tailwindcss.com/3.4.1"></script> 23 - <script src="https://unpkg.com/lucide@latest"></script> 24 - <style 25 - dangerouslySetInnerHTML={{ 26 - __html: ` 27 - 28 - .htmx-indicator { 29 - display: none; 30 - } 31 - 32 - .htmx-request .htmx-indicator { 33 - display: inline; 34 - } 35 - 36 - .htmx-request .default-text { 37 - display: none; 38 - } 39 - `, 40 - }} 41 - /> 42 - </head> 43 - <body className="bg-gray-100 min-h-screen"> 44 - <nav className="bg-white text-gray-800 py-4 border-b border-gray-200"> 45 - <div className="max-w-5xl mx-auto px-4 flex justify-between items-center"> 46 - <a href="/" className="text-xl font-bold hover:text-blue-600"> 47 - Slices 48 - </a> 49 - <div className="flex items-center space-x-4"> 50 - {currentUser?.isAuthenticated ? ( 51 - <div className="flex items-center space-x-3"> 52 - {currentUser.avatar && ( 53 - <img 54 - src={currentUser.avatar} 55 - alt="Profile avatar" 56 - className="w-6 h-6 rounded-full" 57 - /> 58 - )} 59 - <span className="text-sm text-gray-600"> 60 - {currentUser.handle 61 - ? `@${currentUser.handle}` 62 - : "Authenticated User"} 63 - </span> 64 - <a 65 - href="/settings" 66 - className="text-sm text-gray-600 hover:text-gray-800" 67 - > 68 - Settings 69 - </a> 70 - <form method="post" action="/logout" className="inline"> 71 - <button 72 - type="submit" 73 - className="text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded" 74 - > 75 - Logout 76 - </button> 77 - </form> 78 - </div> 79 - ) : ( 80 - <a 81 - href="/login" 82 - className="text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded" 83 - > 84 - Login 85 - </a> 86 - )} 87 - </div> 88 - </div> 89 - </nav> 90 - <main className="max-w-5xl mx-auto mt-8 px-4 pb-16 min-h-[calc(100vh-200px)]"> 91 - {children} 92 - </main> 93 - </body> 94 - </html> 95 - ); 96 - }
frontend/src/components/LexiconErrorMessage.tsx frontend/src/features/slices/lexicon/templates/fragments/LexiconErrorMessage.tsx
+11 -8
frontend/src/components/LexiconListItem.tsx frontend/src/features/slices/lexicon/templates/fragments/LexiconListItem.tsx
··· 1 - import { getRkeyFromUri } from "../utils/at-uri.ts"; 1 + import { getRkeyFromUri } from "../../../../../utils/at-uri.ts"; 2 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 3 3 4 export function LexiconListItem({ 4 5 nsid, ··· 29 30 <p className="text-xs text-gray-400 font-mono">{uri}</p> 30 31 </div> 31 32 <div className="flex items-center space-x-2"> 32 - <button 33 + <Button 33 34 type="button" 35 + variant="primary" 36 + size="sm" 34 37 hx-get={`/api/slices/${sliceId}/lexicons/${rkey}/view`} 35 38 hx-target="#lexicon-modal" 36 39 hx-swap="innerHTML" 37 - className="inline-flex items-center px-2 py-1 border border-blue-300 rounded text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" 38 40 > 39 41 <svg 40 42 className="h-3 w-3 mr-1" ··· 56 58 /> 57 59 </svg> 58 60 View 59 - </button> 60 - <button 61 + </Button> 62 + <Button 61 63 type="button" 64 + variant="danger" 65 + size="sm" 62 66 hx-delete={`/api/slices/${sliceId}/lexicons/${rkey}`} 63 67 hx-target={`#lexicon-${rkey}`} 64 68 hx-swap="outerHTML" 65 69 hx-confirm="Are you sure you want to delete this lexicon?" 66 - className="inline-flex items-center px-2 py-1 border border-red-300 rounded text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" 67 70 > 68 71 <svg 69 72 className="h-3 w-3 mr-1" ··· 79 82 /> 80 83 </svg> 81 84 Delete 82 - </button> 85 + </Button> 83 86 </div> 84 87 </div> 85 88 </div> 86 89 ); 87 - } 90 + }
+1 -1
frontend/src/components/LexiconSuccessMessage.tsx frontend/src/features/slices/lexicon/templates/fragments/LexiconSuccessMessage.tsx
··· 45 45 </div> 46 46 </div> 47 47 ); 48 - } 48 + }
+14 -33
frontend/src/components/LexiconViewModal.tsx frontend/src/features/slices/lexicon/templates/fragments/LexiconViewModal.tsx
··· 1 1 import { codeToHtml } from "jsr:@shikijs/shiki"; 2 - 3 - interface LexiconViewModalProps { 4 - nsid: string; 5 - definitions: string; 6 - uri: string; 7 - createdAt: string; 8 - } 2 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 9 3 10 4 export async function LexiconViewModal({ 11 5 nsid, 12 6 definitions, 13 7 uri, 14 8 createdAt, 15 - }: LexiconViewModalProps) { 16 - // Parse and format the definitions JSON 9 + }: { 10 + nsid: string; 11 + definitions: string; 12 + uri: string; 13 + createdAt: string; 14 + }) { 17 15 let formattedDefinitions = definitions; 18 16 try { 19 17 const parsed = JSON.parse(definitions); ··· 22 20 // Keep original if parsing fails 23 21 } 24 22 25 - // Apply Shiki syntax highlighting 26 23 const highlightedJson = await codeToHtml(formattedDefinitions, { 27 24 lang: "json", 28 25 theme: "catppuccin-latte", ··· 31 28 return ( 32 29 <div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"> 33 30 <div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden"> 34 - {/* Header */} 35 31 <div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center"> 36 32 <div> 37 33 <h3 className="text-lg font-semibold text-gray-900 font-mono"> ··· 63 59 </button> 64 60 </div> 65 61 66 - {/* Content */} 67 62 <div className="px-6 py-4 overflow-y-auto max-h-[calc(90vh-120px)]"> 68 63 <div className="mb-4"> 69 64 <label className="block text-sm font-medium text-gray-700 mb-2"> ··· 75 70 </div> 76 71 </div> 77 72 78 - {/* Footer */} 79 73 <div className="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3"> 80 - <button 74 + <Button 81 75 type="button" 82 - _="on click js navigator.clipboard.writeText(me.previousElementSibling.textContent) then set my textContent to 'Copied!' then wait 2s then set my textContent to 'Copy JSON' end" 83 - className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" 76 + variant="secondary" 77 + _="on click js navigator.clipboard.writeText(me.previousElementSibling.textContent) end then set my textContent to 'Copied!' then wait 2s then set my textContent to 'Copy JSON'" 84 78 > 85 - <svg 86 - className="h-4 w-4 mr-2" 87 - fill="none" 88 - viewBox="0 0 24 24" 89 - stroke="currentColor" 90 - > 91 - <path 92 - strokeLinecap="round" 93 - strokeLinejoin="round" 94 - strokeWidth={2} 95 - d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" 96 - /> 97 - </svg> 98 79 Copy JSON 99 - </button> 80 + </Button> 100 81 <span className="hidden">{formattedDefinitions}</span> 101 - <button 82 + <Button 102 83 type="button" 84 + variant="primary" 103 85 _="on click set #lexicon-modal's innerHTML to ''" 104 - className="inline-flex items-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" 105 86 > 106 87 Close 107 - </button> 88 + </Button> 108 89 </div> 109 90 </div> 110 91 </div>
-370
frontend/src/components/OAuthClientModal.tsx
··· 1 - import { OAuthClientDetails } from "../client.ts"; 2 - 3 - interface OAuthClientModalProps { 4 - sliceId: string; 5 - sliceUri: string; 6 - mode: "new" | "view"; 7 - clientData?: OAuthClientDetails; 8 - } 9 - 10 - export function OAuthClientModal({ 11 - sliceId, 12 - sliceUri, 13 - mode, 14 - clientData, 15 - }: OAuthClientModalProps) { 16 - if (mode === "view" && clientData) { 17 - 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-2xl w-full max-h-[90vh] overflow-y-auto"> 20 - <form 21 - hx-post={`/api/slices/${sliceId}/oauth/${encodeURIComponent(clientData.clientId)}/update`} 22 - hx-target="#modal-container" 23 - hx-swap="outerHTML" 24 - > 25 - <div className="flex justify-between items-start mb-4"> 26 - <h2 className="text-2xl font-semibold">OAuth Client Details</h2> 27 - <button 28 - type="button" 29 - _="on click set #modal-container's innerHTML to ''" 30 - className="text-gray-400 hover:text-gray-600" 31 - > 32 - 33 - </button> 34 - </div> 35 - 36 - <div className="space-y-4"> 37 - {/* Client ID - Read-only */} 38 - <div> 39 - <label className="block text-sm font-medium text-gray-700 mb-1"> 40 - Client ID 41 - </label> 42 - <div className="font-mono text-sm bg-gray-100 p-2 rounded border"> 43 - {clientData.clientId} 44 - </div> 45 - </div> 46 - 47 - {/* Client Secret - Read-only, only shown once */} 48 - {clientData.clientSecret && ( 49 - <div> 50 - <label className="block text-sm font-medium text-gray-700 mb-1"> 51 - Client Secret 52 - </label> 53 - <div className="font-mono text-sm bg-yellow-50 border border-yellow-200 p-2 rounded"> 54 - <div className="text-yellow-800 text-xs mb-1">⚠️ Save this secret - it won't be shown again</div> 55 - {clientData.clientSecret} 56 - </div> 57 - </div> 58 - )} 59 - 60 - {/* Client Name - Editable */} 61 - <div> 62 - <label 63 - htmlFor="clientName" 64 - className="block text-sm font-medium text-gray-700 mb-1" 65 - > 66 - Client Name <span className="text-red-500">*</span> 67 - </label> 68 - <input 69 - type="text" 70 - id="clientName" 71 - name="clientName" 72 - required 73 - defaultValue={clientData.clientName} 74 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 75 - /> 76 - </div> 77 - 78 - {/* Redirect URIs - Editable */} 79 - <div> 80 - <label 81 - htmlFor="redirectUris" 82 - className="block text-sm font-medium text-gray-700 mb-1" 83 - > 84 - Redirect URIs <span className="text-red-500">*</span> 85 - </label> 86 - <textarea 87 - id="redirectUris" 88 - name="redirectUris" 89 - required 90 - rows={3} 91 - defaultValue={clientData.redirectUris.join('\n')} 92 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 93 - /> 94 - <p className="text-sm text-gray-500 mt-1"> 95 - Enter one redirect URI per line 96 - </p> 97 - </div> 98 - 99 - {/* Scope - Editable */} 100 - <div> 101 - <label 102 - htmlFor="scope" 103 - className="block text-sm font-medium text-gray-700 mb-1" 104 - > 105 - Scope 106 - </label> 107 - <input 108 - type="text" 109 - id="scope" 110 - name="scope" 111 - defaultValue={clientData.scope || ''} 112 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 113 - placeholder="atproto:atproto" 114 - /> 115 - </div> 116 - 117 - {/* Client URI - Editable */} 118 - <div> 119 - <label 120 - htmlFor="clientUri" 121 - className="block text-sm font-medium text-gray-700 mb-1" 122 - > 123 - Client URI 124 - </label> 125 - <input 126 - type="url" 127 - id="clientUri" 128 - name="clientUri" 129 - defaultValue={clientData.clientUri || ''} 130 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 131 - placeholder="https://example.com" 132 - /> 133 - </div> 134 - 135 - {/* Logo URI - Editable */} 136 - <div> 137 - <label 138 - htmlFor="logoUri" 139 - className="block text-sm font-medium text-gray-700 mb-1" 140 - > 141 - Logo URI 142 - </label> 143 - <input 144 - type="url" 145 - id="logoUri" 146 - name="logoUri" 147 - defaultValue={clientData.logoUri || ''} 148 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 149 - placeholder="https://example.com/logo.png" 150 - /> 151 - </div> 152 - 153 - {/* Terms of Service URI - Editable */} 154 - <div> 155 - <label 156 - htmlFor="tosUri" 157 - className="block text-sm font-medium text-gray-700 mb-1" 158 - > 159 - Terms of Service URI 160 - </label> 161 - <input 162 - type="url" 163 - id="tosUri" 164 - name="tosUri" 165 - defaultValue={clientData.tosUri || ''} 166 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 167 - placeholder="https://example.com/terms" 168 - /> 169 - </div> 170 - 171 - {/* Privacy Policy URI - Editable */} 172 - <div> 173 - <label 174 - htmlFor="policyUri" 175 - className="block text-sm font-medium text-gray-700 mb-1" 176 - > 177 - Privacy Policy URI 178 - </label> 179 - <input 180 - type="url" 181 - id="policyUri" 182 - name="policyUri" 183 - defaultValue={clientData.policyUri || ''} 184 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 185 - placeholder="https://example.com/privacy" 186 - /> 187 - </div> 188 - 189 - <div className="flex justify-end gap-3 mt-6"> 190 - <button 191 - type="button" 192 - _="on click set #modal-container's innerHTML to ''" 193 - className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300" 194 - > 195 - Cancel 196 - </button> 197 - <button 198 - type="submit" 199 - className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" 200 - > 201 - Update Client 202 - </button> 203 - </div> 204 - </div> 205 - </form> 206 - </div> 207 - </div> 208 - ); 209 - } 210 - 211 - return ( 212 - <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 213 - <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 214 - <form 215 - hx-post={`/api/slices/${sliceId}/oauth/register`} 216 - hx-target="#modal-container" 217 - hx-swap="outerHTML" 218 - > 219 - <input type="hidden" name="sliceUri" value={sliceUri} /> 220 - 221 - <div className="flex justify-between items-start mb-4"> 222 - <h2 className="text-2xl font-semibold">Register OAuth Client</h2> 223 - <button 224 - type="button" 225 - _="on click set #modal-container's innerHTML to ''" 226 - className="text-gray-400 hover:text-gray-600" 227 - > 228 - 229 - </button> 230 - </div> 231 - 232 - <div className="space-y-4"> 233 - <div> 234 - <label 235 - htmlFor="clientName" 236 - className="block text-sm font-medium text-gray-700 mb-1" 237 - > 238 - Client Name <span className="text-red-500">*</span> 239 - </label> 240 - <input 241 - type="text" 242 - id="clientName" 243 - name="clientName" 244 - required 245 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 246 - placeholder="My Application" 247 - /> 248 - </div> 249 - 250 - <div> 251 - <label 252 - htmlFor="redirectUris" 253 - className="block text-sm font-medium text-gray-700 mb-1" 254 - > 255 - Redirect URIs <span className="text-red-500">*</span> 256 - </label> 257 - <textarea 258 - id="redirectUris" 259 - name="redirectUris" 260 - required 261 - rows={3} 262 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 263 - placeholder="https://example.com/callback&#10;https://localhost:3000/callback" 264 - /> 265 - <p className="text-sm text-gray-500 mt-1"> 266 - Enter one redirect URI per line 267 - </p> 268 - </div> 269 - 270 - <div> 271 - <label 272 - htmlFor="scope" 273 - className="block text-sm font-medium text-gray-700 mb-1" 274 - > 275 - Scope 276 - </label> 277 - <input 278 - type="text" 279 - id="scope" 280 - name="scope" 281 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 282 - placeholder="atproto:atproto" 283 - /> 284 - </div> 285 - 286 - <div> 287 - <label 288 - htmlFor="clientUri" 289 - className="block text-sm font-medium text-gray-700 mb-1" 290 - > 291 - Client URI 292 - </label> 293 - <input 294 - type="url" 295 - id="clientUri" 296 - name="clientUri" 297 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 298 - placeholder="https://example.com" 299 - /> 300 - </div> 301 - 302 - <div> 303 - <label 304 - htmlFor="logoUri" 305 - className="block text-sm font-medium text-gray-700 mb-1" 306 - > 307 - Logo URI 308 - </label> 309 - <input 310 - type="url" 311 - id="logoUri" 312 - name="logoUri" 313 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 314 - placeholder="https://example.com/logo.png" 315 - /> 316 - </div> 317 - 318 - <div> 319 - <label 320 - htmlFor="tosUri" 321 - className="block text-sm font-medium text-gray-700 mb-1" 322 - > 323 - Terms of Service URI 324 - </label> 325 - <input 326 - type="url" 327 - id="tosUri" 328 - name="tosUri" 329 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 330 - placeholder="https://example.com/terms" 331 - /> 332 - </div> 333 - 334 - <div> 335 - <label 336 - htmlFor="policyUri" 337 - className="block text-sm font-medium text-gray-700 mb-1" 338 - > 339 - Privacy Policy URI 340 - </label> 341 - <input 342 - type="url" 343 - id="policyUri" 344 - name="policyUri" 345 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 346 - placeholder="https://example.com/privacy" 347 - /> 348 - </div> 349 - 350 - <div className="flex justify-end gap-3 mt-6"> 351 - <button 352 - type="button" 353 - _="on click set #modal-container's innerHTML to ''" 354 - className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300" 355 - > 356 - Cancel 357 - </button> 358 - <button 359 - type="submit" 360 - className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" 361 - > 362 - Register Client 363 - </button> 364 - </div> 365 - </div> 366 - </form> 367 - </div> 368 - </div> 369 - ); 370 - }
-17
frontend/src/components/OAuthDeleteResult.tsx
··· 1 - interface OAuthDeleteResultProps { 2 - success: boolean; 3 - error?: string; 4 - } 5 - 6 - export function OAuthDeleteResult({ success, error }: OAuthDeleteResultProps) { 7 - if (!success) { 8 - return ( 9 - <div className="text-red-600"> 10 - Failed to delete OAuth client{error ? `: ${error}` : ""} 11 - </div> 12 - ); 13 - } 14 - 15 - // Return empty for successful deletion (removes the row) 16 - return null; 17 - }
-55
frontend/src/components/OAuthRegistrationResult.tsx
··· 1 - interface OAuthRegistrationResultProps { 2 - success: boolean; 3 - sliceId: string; 4 - clientId?: string; 5 - registrationToken?: string; 6 - error?: string; 7 - } 8 - 9 - export function OAuthRegistrationResult({ 10 - success, 11 - sliceId, 12 - clientId, 13 - registrationToken, 14 - error, 15 - }: OAuthRegistrationResultProps) { 16 - if (!success) { 17 - return ( 18 - <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 19 - ❌ Failed to register OAuth client: {error} 20 - </div> 21 - ); 22 - } 23 - 24 - return ( 25 - <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"> 26 - <div class="font-semibold mb-2"> 27 - ✅ OAuth client registered successfully! 28 - </div> 29 - <div class="mb-2"> 30 - <span class="font-medium">Client ID:</span>{" "} 31 - <code class="bg-green-200 px-1 rounded">{clientId}</code> 32 - </div> 33 - {registrationToken && ( 34 - <div class="bg-yellow-50 border border-yellow-400 text-yellow-800 p-3 rounded mb-3"> 35 - <div class="font-semibold mb-1"> 36 - ⚠️ Important: Save this registration access token 37 - </div> 38 - <div class="text-sm mb-2"> 39 - This token won't be shown again. Store it securely to manage this 40 - client. 41 - </div> 42 - <code class="block bg-yellow-100 p-2 rounded text-xs break-all"> 43 - {registrationToken} 44 - </code> 45 - </div> 46 - )} 47 - <a 48 - href={`/slices/${sliceId}/oauth`} 49 - class="inline-block mt-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-decoration-none" 50 - > 51 - Continue 52 - </a> 53 - </div> 54 - ); 55 - }
-90
frontend/src/components/SettingsForm.tsx
··· 1 - interface SettingsFormProps { 2 - profile?: { 3 - displayName?: string; 4 - description?: string; 5 - avatar?: string; 6 - }; 7 - error?: string; 8 - } 9 - 10 - export function SettingsForm({ profile, error }: SettingsFormProps) { 11 - return ( 12 - <div className="bg-white rounded-lg shadow-md p-6"> 13 - <h2 className="text-xl font-semibold text-gray-800 mb-4"> 14 - Profile Settings 15 - </h2> 16 - 17 - {error && ( 18 - <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4"> 19 - {error} 20 - </div> 21 - )} 22 - 23 - <form 24 - hx-put="/api/profile" 25 - hx-target="#settings-result" 26 - hx-swap="innerHTML" 27 - hx-encoding="multipart/form-data" 28 - className="space-y-4" 29 - > 30 - <div> 31 - <label className="block text-sm font-medium text-gray-700 mb-2"> 32 - Display Name 33 - </label> 34 - <input 35 - type="text" 36 - name="displayName" 37 - value={profile?.displayName || ""} 38 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 39 - placeholder="Your display name" 40 - /> 41 - </div> 42 - 43 - <div> 44 - <label className="block text-sm font-medium text-gray-700 mb-2"> 45 - Description 46 - </label> 47 - <textarea 48 - name="description" 49 - rows={4} 50 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 51 - placeholder="Free-form profile description text" 52 - > 53 - {profile?.description || ""} 54 - </textarea> 55 - </div> 56 - 57 - <div> 58 - <label className="block text-sm font-medium text-gray-700 mb-2"> 59 - Avatar 60 - </label> 61 - <input 62 - type="file" 63 - name="avatar" 64 - accept="image/*" 65 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 66 - /> 67 - </div> 68 - 69 - <button 70 - type="submit" 71 - className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-md" 72 - hx-indicator="#loading-spinner" 73 - > 74 - <span 75 - className="htmx-indicator" 76 - id="loading-spinner" 77 - style="display:none;" 78 - > 79 - Saving... 80 - </span> 81 - <span>Save Profile</span> 82 - </button> 83 - </form> 84 - 85 - <div id="settings-result" className="mt-4"> 86 - {/* Results will be loaded here via htmx */} 87 - </div> 88 - </div> 89 - ); 90 - }
+1 -1
frontend/src/components/SettingsResult.tsx frontend/src/features/settings/templates/fragments/SettingsResult.tsx
··· 32 32 )} 33 33 </div> 34 34 ); 35 - } 35 + }
+1 -1
frontend/src/components/SliceTabs.tsx frontend/src/features/slices/shared/fragments/SliceTabs.tsx
··· 8 8 return [ 9 9 { id: "overview", name: "Overview", href: `/slices/${sliceId}` }, 10 10 { id: "lexicon", name: "Lexicons", href: `/slices/${sliceId}/lexicon` }, 11 - { id: "records", name: "Records", href: `/slices/${sliceId}/records` }, 12 11 { id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` }, 12 + { id: "records", name: "Records", href: `/slices/${sliceId}/records` }, 13 13 { id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` }, 14 14 { id: "oauth", name: "OAuth Clients", href: `/slices/${sliceId}/oauth` }, 15 15 { id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
-121
frontend/src/components/SyncJobLogs.tsx
··· 1 - import { formatTimestamp } from "../utils/time.ts"; 2 - 3 - interface LogEntry { 4 - id: number; 5 - createdAt: string; 6 - logType: string; 7 - jobId?: string; 8 - userDid?: string; 9 - sliceUri?: string; 10 - level: string; 11 - message: string; 12 - metadata?: Record<string, unknown>; 13 - } 14 - 15 - interface SyncJobLogsProps { 16 - logs: LogEntry[]; 17 - jobId?: string; 18 - } 19 - 20 - function LogLevelBadge({ level }: { level: string }) { 21 - const colors: Record<string, string> = { 22 - error: "bg-red-100 text-red-800", 23 - warn: "bg-yellow-100 text-yellow-800", 24 - info: "bg-blue-100 text-blue-800", 25 - debug: "bg-gray-100 text-gray-800", 26 - }; 27 - 28 - return ( 29 - <span 30 - className={`px-2 py-1 rounded text-xs font-medium ${ 31 - colors[level] || colors.debug 32 - }`} 33 - > 34 - {level.toUpperCase()} 35 - </span> 36 - ); 37 - } 38 - 39 - export function SyncJobLogs({ logs, jobId }: SyncJobLogsProps) { 40 - if (logs.length === 0) { 41 - return ( 42 - <div className="p-8 text-center text-gray-500"> 43 - No logs found for this job 44 - {jobId && ( 45 - <div className="text-xs text-gray-400 mt-1 font-mono"> 46 - Job ID: {jobId} 47 - </div> 48 - )} 49 - </div> 50 - ); 51 - } 52 - 53 - const errorCount = logs.filter((l) => l.level === "error").length; 54 - const warnCount = logs.filter((l) => l.level === "warn").length; 55 - const infoCount = logs.filter((l) => l.level === "info").length; 56 - 57 - return ( 58 - <div className="divide-y divide-gray-200"> 59 - {/* Log Stats Header */} 60 - <div className="p-4 bg-gray-50"> 61 - <div className="flex gap-4 text-sm"> 62 - <span> 63 - Total logs: <strong>{logs.length}</strong> 64 - </span> 65 - {errorCount > 0 && ( 66 - <span className="text-red-600"> 67 - Errors: <strong>{errorCount}</strong> 68 - </span> 69 - )} 70 - {warnCount > 0 && ( 71 - <span className="text-yellow-600"> 72 - Warnings: <strong>{warnCount}</strong> 73 - </span> 74 - )} 75 - <span className="text-blue-600"> 76 - Info: <strong>{infoCount}</strong> 77 - </span> 78 - </div> 79 - </div> 80 - 81 - {/* Log Entries */} 82 - <div className="max-h-[600px] overflow-y-auto"> 83 - {logs.map((log) => ( 84 - <div 85 - key={log.id} 86 - className={`p-3 hover:bg-gray-50 font-mono text-sm ${ 87 - log.level === "error" 88 - ? "bg-red-50" 89 - : log.level === "warn" 90 - ? "bg-yellow-50" 91 - : "" 92 - }`} 93 - > 94 - <div className="flex items-start gap-3"> 95 - <span className="text-gray-400 text-xs"> 96 - {formatTimestamp(log.createdAt)} 97 - </span> 98 - <LogLevelBadge level={log.level} /> 99 - <div className="flex-1"> 100 - <div className="text-gray-800">{log.message}</div> 101 - {log.metadata && Object.keys(log.metadata).length > 0 && ( 102 - <details className="mt-2"> 103 - <summary 104 - className="text-xs text-gray-500 cursor-pointer hover:text-gray-700" 105 - _="on click toggle .hidden on next <pre/>" 106 - > 107 - View metadata 108 - </summary> 109 - <pre className="mt-2 p-2 bg-gray-100 rounded text-xs overflow-x-auto hidden"> 110 - {JSON.stringify(log.metadata, null, 2)} 111 - </pre> 112 - </details> 113 - )} 114 - </div> 115 - </div> 116 - </div> 117 - ))} 118 - </div> 119 - </div> 120 - ); 121 - }
+1 -1
frontend/src/components/SyncResult.tsx frontend/src/features/slices/sync/templates/fragments/SyncResult.tsx
··· 42 42 </div> 43 43 </div> 44 44 ); 45 - } 45 + }
-32
frontend/src/components/UpdateResult.tsx
··· 1 - interface UpdateResultProps { 2 - type: "success" | "error"; 3 - message: string; 4 - showRefresh?: boolean; 5 - } 6 - 7 - export function UpdateResult({ 8 - type, 9 - message, 10 - showRefresh = false, 11 - }: UpdateResultProps) { 12 - const colorClass = type === "success" ? "text-green-600" : "text-red-600"; 13 - 14 - return ( 15 - <div className={`${colorClass} text-sm`}> 16 - {message} 17 - {showRefresh && ( 18 - <> 19 - {" "} 20 - <a 21 - href="#" 22 - _="on click call window.location.reload()" 23 - className="underline" 24 - > 25 - Refresh page 26 - </a>{" "} 27 - to see changes. 28 - </> 29 - )} 30 - </div> 31 - ); 32 - }
+56
frontend/src/features/auth/templates/LoginPage.tsx
··· 1 + import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 2 + import { Layout } from "../../../shared/fragments/Layout.tsx"; 3 + import { LoginForm } from "./fragments/LoginForm.tsx"; 4 + import { ErrorAlert } from "./fragments/ErrorAlert.tsx"; 5 + 6 + interface LoginPageProps { 7 + error?: string; 8 + currentUser?: AuthenticatedUser; 9 + } 10 + 11 + export function LoginPage({ error, currentUser }: LoginPageProps) { 12 + return ( 13 + <Layout title="Login - Slice" currentUser={currentUser}> 14 + <div className="max-w-md mx-auto mt-16"> 15 + <div className="bg-white rounded-lg shadow-md p-8"> 16 + <div className="text-center mb-8"> 17 + <h1 className="text-3xl font-bold text-gray-800 mb-2"> 18 + Welcome to Slices 19 + </h1> 20 + <p className="text-gray-600"> 21 + Sign in with your AT Protocol handle 22 + </p> 23 + </div> 24 + 25 + {error && <ErrorAlert message={error} />} 26 + 27 + <LoginForm /> 28 + 29 + <div className="mt-8 text-center"> 30 + <p className="text-sm text-gray-500 mb-4"> 31 + Don't have an AT Protocol account? 32 + </p> 33 + <div className="space-y-2"> 34 + <a 35 + href="https://bsky.app" 36 + target="_blank" 37 + rel="noopener noreferrer" 38 + className="block text-blue-600 hover:text-blue-800 text-sm" 39 + > 40 + Create account on Bluesky → 41 + </a> 42 + <a 43 + href="https://atproto.com" 44 + target="_blank" 45 + rel="noopener noreferrer" 46 + className="block text-blue-600 hover:text-blue-800 text-sm" 47 + > 48 + Learn about AT Protocol → 49 + </a> 50 + </div> 51 + </div> 52 + </div> 53 + </div> 54 + </Layout> 55 + ); 56 + }
+11
frontend/src/features/auth/templates/fragments/ErrorAlert.tsx
··· 1 + interface ErrorAlertProps { 2 + message: string; 3 + } 4 + 5 + export function ErrorAlert({ message }: ErrorAlertProps) { 6 + return ( 7 + <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6"> 8 + <p className="text-red-700 text-sm">{message}</p> 9 + </div> 10 + ); 11 + }
+32
frontend/src/features/auth/templates/fragments/LoginForm.tsx
··· 1 + export function LoginForm() { 2 + return ( 3 + <form method="post" action="/oauth/authorize" className="space-y-6"> 4 + <div> 5 + <label 6 + htmlFor="loginHint" 7 + className="block text-sm font-medium text-gray-700 mb-2" 8 + > 9 + AT Protocol Handle 10 + </label> 11 + <input 12 + type="text" 13 + id="loginHint" 14 + name="loginHint" 15 + placeholder="alice.bsky.social" 16 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" 17 + required 18 + /> 19 + <p className="text-xs text-gray-500 mt-1"> 20 + Enter your Bluesky handle or custom domain 21 + </p> 22 + </div> 23 + 24 + <button 25 + type="submit" 26 + className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-md transition-colors" 27 + > 28 + Sign In with OAuth 29 + </button> 30 + </form> 31 + ); 32 + }
+168
frontend/src/features/dashboard/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { renderHTML } from "../../utils/render.tsx"; 3 + import { hxRedirect } from "../../utils/htmx.ts"; 4 + import { withAuth, requireAuth } from "../../routes/middleware.ts"; 5 + import { atprotoClient } from "../../config.ts"; 6 + import { DashboardPage } from "./templates/DashboardPage.tsx"; 7 + import { CreateSliceDialog } from "./templates/fragments/CreateSliceDialog.tsx"; 8 + import type { SocialSlicesSlice } from "../../client.ts"; 9 + 10 + interface Slice extends SocialSlicesSlice { 11 + id: string; 12 + } 13 + 14 + async function handleProfilePage(req: Request, params?: URLPatternResult): Promise<Response> { 15 + const context = await withAuth(req); 16 + const authResponse = requireAuth(context); 17 + if (authResponse) return authResponse; 18 + 19 + const handle = params?.pathname.groups.handle as string; 20 + 21 + // Get actor by handle to find DID 22 + let profileDid: string; 23 + try { 24 + const actors = await atprotoClient.getActors({ 25 + where: { handle: { eq: handle } } 26 + }); 27 + 28 + if (actors.actors.length === 0) { 29 + return new Response("Profile not found", { status: 404 }); 30 + } 31 + 32 + profileDid = actors.actors[0].did; 33 + } catch (error) { 34 + console.error("Failed to get actor:", error); 35 + return new Response("Profile not found", { status: 404 }); 36 + } 37 + 38 + // Fetch profile record using the DID 39 + let _profileRecord; 40 + try { 41 + const profileResponse = await atprotoClient.app.bsky.actor.profile.getRecords({ 42 + where: { did: { eq: profileDid } } 43 + }); 44 + _profileRecord = profileResponse.records[0]; 45 + } catch (error) { 46 + console.error("Failed to fetch profile:", error); 47 + } 48 + 49 + let slices: Slice[] = []; 50 + 51 + try { 52 + // Fetch slices for this DID 53 + const sliceRecords = await atprotoClient.social.slices.slice.getRecords({ 54 + where: { did: { eq: profileDid } }, 55 + sortBy: [{ field: "createdAt", direction: "desc" }], 56 + }); 57 + 58 + slices = sliceRecords.records.map((record) => { 59 + const uriParts = record.uri.split("/"); 60 + const id = uriParts[uriParts.length - 1]; 61 + 62 + return { 63 + id, 64 + ...record.value, 65 + }; 66 + }); 67 + } catch (error) { 68 + console.error("Failed to fetch slices:", error); 69 + } 70 + 71 + return renderHTML( 72 + <DashboardPage slices={slices} currentUser={context.currentUser} /> 73 + ); 74 + } 75 + 76 + async function handleCreateSlice(req: Request): Promise<Response> { 77 + const context = await withAuth(req); 78 + const authResponse = requireAuth(context); 79 + if (authResponse) return authResponse; 80 + 81 + const authInfo = await atprotoClient.oauth?.getAuthenticationInfo(); 82 + if (!authInfo?.isAuthenticated) { 83 + return renderHTML( 84 + <CreateSliceDialog error="Session expired. Please log in again." /> 85 + ); 86 + } 87 + 88 + try { 89 + const formData = await req.formData(); 90 + const name = formData.get("name") as string; 91 + const domain = formData.get("domain") as string; 92 + 93 + if (!name || name.trim().length === 0) { 94 + return renderHTML( 95 + <CreateSliceDialog 96 + error="Slice name is required" 97 + name={name} 98 + domain={domain} 99 + /> 100 + ); 101 + } 102 + 103 + if (!domain || domain.trim().length === 0) { 104 + return renderHTML( 105 + <CreateSliceDialog 106 + error="Primary domain is required" 107 + name={name} 108 + domain={domain} 109 + /> 110 + ); 111 + } 112 + 113 + try { 114 + const recordData = { 115 + name: name.trim(), 116 + domain: domain.trim(), 117 + createdAt: new Date().toISOString(), 118 + }; 119 + 120 + const result = await atprotoClient.social.slices.slice.createRecord( 121 + recordData 122 + ); 123 + 124 + const uriParts = result.uri.split("/"); 125 + const sliceId = uriParts[uriParts.length - 1]; 126 + 127 + return hxRedirect(`/slices/${sliceId}`); 128 + } catch (_createError) { 129 + return renderHTML( 130 + <CreateSliceDialog 131 + error="Failed to create slice record. Please try again." 132 + name={name} 133 + domain={domain} 134 + /> 135 + ); 136 + } 137 + } catch (_error) { 138 + return renderHTML( 139 + <CreateSliceDialog error="Failed to create slice" /> 140 + ); 141 + } 142 + } 143 + 144 + async function handleCreateSliceDialog(req: Request): Promise<Response> { 145 + const context = await withAuth(req); 146 + const authResponse = requireAuth(context); 147 + if (authResponse) return authResponse; 148 + 149 + return renderHTML(<CreateSliceDialog />); 150 + } 151 + 152 + export const dashboardRoutes: Route[] = [ 153 + { 154 + method: "GET", 155 + pattern: new URLPattern({ pathname: "/profile/:handle" }), 156 + handler: handleProfilePage, 157 + }, 158 + { 159 + method: "POST", 160 + pattern: new URLPattern({ pathname: "/slices" }), 161 + handler: handleCreateSlice, 162 + }, 163 + { 164 + method: "GET", 165 + pattern: new URLPattern({ pathname: "/dialogs/create-slice" }), 166 + handler: handleCreateSliceDialog, 167 + }, 168 + ];
+42
frontend/src/features/dashboard/templates/DashboardPage.tsx
··· 1 + import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 + import { Button } from "../../../shared/fragments/Button.tsx"; 3 + import { SlicesList } from "./fragments/SlicesList.tsx"; 4 + import { EmptySlicesState } from "./fragments/EmptySlicesState.tsx"; 5 + import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 6 + import type { SocialSlicesSlice } from "../../../client.ts"; 7 + 8 + interface Slice extends SocialSlicesSlice { 9 + id: string; 10 + } 11 + 12 + interface DashboardPageProps { 13 + slices?: Slice[]; 14 + currentUser?: AuthenticatedUser; 15 + } 16 + 17 + export function DashboardPage({ slices = [], currentUser }: DashboardPageProps) { 18 + return ( 19 + <Layout title="Slices" currentUser={currentUser}> 20 + <div> 21 + <div className="flex justify-between items-center mb-8"> 22 + <h1 className="text-3xl font-bold text-gray-800">Slices</h1> 23 + <Button 24 + type="button" 25 + variant="primary" 26 + hx-get="/dialogs/create-slice" 27 + hx-target="body" 28 + hx-swap="beforeend" 29 + > 30 + + Create Slice 31 + </Button> 32 + </div> 33 + 34 + {slices.length > 0 ? ( 35 + <SlicesList slices={slices} /> 36 + ) : ( 37 + <EmptySlicesState /> 38 + )} 39 + </div> 40 + </Layout> 41 + ); 42 + }
+39
frontend/src/features/dashboard/templates/fragments/EmptySlicesState.tsx
··· 1 + import { Button } from "../../../../shared/fragments/Button.tsx"; 2 + 3 + export function EmptySlicesState() { 4 + return ( 5 + <div className="bg-white rounded-lg shadow-md p-8 text-center"> 6 + <div className="text-gray-400 mb-4"> 7 + <svg 8 + className="mx-auto h-16 w-16" 9 + fill="none" 10 + viewBox="0 0 24 24" 11 + stroke="currentColor" 12 + > 13 + <path 14 + strokeLinecap="round" 15 + strokeLinejoin="round" 16 + strokeWidth={1} 17 + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 18 + /> 19 + </svg> 20 + </div> 21 + <h3 className="text-lg font-medium text-gray-900 mb-2"> 22 + No slices yet 23 + </h3> 24 + <p className="text-gray-500 mb-6"> 25 + Create your first slice to get started organizing your AT Protocol 26 + data. 27 + </p> 28 + <Button 29 + type="button" 30 + variant="primary" 31 + hx-get="/dialogs/create-slice" 32 + hx-target="body" 33 + hx-swap="beforeend" 34 + > 35 + Create Your First Slice 36 + </Button> 37 + </div> 38 + ); 39 + }
+56
frontend/src/features/dashboard/templates/fragments/SlicesList.tsx
··· 1 + import type { SocialSlicesSlice } from "../../../../client.ts"; 2 + 3 + interface Slice extends SocialSlicesSlice { 4 + id: string; 5 + } 6 + 7 + interface SlicesListProps { 8 + slices: Slice[]; 9 + } 10 + 11 + export function SlicesList({ slices }: SlicesListProps) { 12 + return ( 13 + <div className="bg-white rounded-lg shadow-md"> 14 + <div className="px-6 py-4 border-b border-gray-200"> 15 + <h2 className="text-lg font-semibold text-gray-800"> 16 + Your Slices ({slices.length}) 17 + </h2> 18 + </div> 19 + <div className="divide-y divide-gray-200"> 20 + {slices.map((slice) => ( 21 + <a 22 + key={slice.id} 23 + href={`/slices/${slice.id}`} 24 + className="block px-6 py-4 hover:bg-gray-50" 25 + > 26 + <div className="flex justify-between items-center"> 27 + <div> 28 + <h3 className="text-lg font-medium text-gray-900"> 29 + {slice.name} 30 + </h3> 31 + <p className="text-sm text-gray-500"> 32 + Created {new Date(slice.createdAt).toLocaleDateString()} 33 + </p> 34 + </div> 35 + <div className="text-gray-400"> 36 + <svg 37 + className="h-5 w-5" 38 + fill="none" 39 + viewBox="0 0 24 24" 40 + stroke="currentColor" 41 + > 42 + <path 43 + strokeLinecap="round" 44 + strokeLinejoin="round" 45 + strokeWidth={2} 46 + d="M9 5l7 7-7 7" 47 + /> 48 + </svg> 49 + </div> 50 + </div> 51 + </a> 52 + ))} 53 + </div> 54 + </div> 55 + ); 56 + }
+26
frontend/src/features/landing/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { renderHTML } from "../../utils/render.tsx"; 3 + import { withAuth } from "../../routes/middleware.ts"; 4 + import { LandingPage } from "./templates/LandingPage.tsx"; 5 + 6 + async function handleLandingPage(req: Request): Promise<Response> { 7 + const context = await withAuth(req); 8 + 9 + // If user is authenticated and has a handle, redirect to their profile 10 + if (context.currentUser?.isAuthenticated && context.currentUser.handle) { 11 + return Response.redirect(new URL(`/profile/${context.currentUser.handle}`, req.url), 302); 12 + } 13 + 14 + return renderHTML(<LandingPage />, { 15 + title: "Slice - AT Protocol Data Management Platform", 16 + description: 17 + "Build, manage, and integrate with AT Protocol data effortlessly. Create custom lexicons, sync records, and generate TypeScript clients.", 18 + }); 19 + } 20 + 21 + export const landingRoutes: Route[] = [ 22 + { 23 + pattern: new URLPattern({ pathname: "/" }), 24 + handler: handleLandingPage, 25 + }, 26 + ];
+183
frontend/src/features/landing/templates/LandingPage.tsx
··· 1 + import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 + 3 + export function LandingPage() { 4 + return ( 5 + <Layout 6 + title="Slice - AT Protocol Data Management Platform" 7 + description="Build, manage, and integrate with AT Protocol data effortlessly. Create custom lexicons, sync records, and generate TypeScript clients." 8 + showNavigation={false} 9 + > 10 + <div class="min-h-screen bg-white"> 11 + {/* Header */} 12 + <header class="bg-white border-b border-gray-200"> 13 + <div class="container mx-auto px-8 py-6"> 14 + <div class="max-w-4xl mx-auto"> 15 + <div class="font-mono text-4xl font-bold text-gray-800 mb-2"> 16 + SLICES 17 + </div> 18 + </div> 19 + </div> 20 + </header> 21 + 22 + {/* Menu Section */} 23 + <section class="py-16 bg-white"> 24 + <div class="container mx-auto px-8"> 25 + <div class="max-w-4xl mx-auto"> 26 + {/* Menu Header */} 27 + <div class="font-mono text-sm text-gray-700 mb-12 leading-relaxed"> 28 + Artisanal{" "} 29 + <a 30 + href="https://atproto.com/" 31 + class="underline hover:text-gray-900" 32 + > 33 + AT Protocol 34 + </a>{" "} 35 + AppViews 36 + <br /> 37 + </div> 38 + 39 + {/* Data Synchronization */} 40 + <div class="mb-12"> 41 + <h2 class="font-mono text-lg font-bold text-gray-800 mb-6"> 42 + Data Synchronization 43 + </h2> 44 + <div class="font-mono text-sm space-y-3"> 45 + <div class="flex justify-between"> 46 + <span>Automated Firehose sync</span> 47 + <span>market price</span> 48 + </div> 49 + <div class="flex justify-between"> 50 + <span>Real-time creates, updates & deletes</span> 51 + <span></span> 52 + </div> 53 + <div class="flex justify-between"> 54 + <span>Network backfill w/sync logs</span> 55 + <span></span> 56 + </div> 57 + <div class="flex justify-between"> 58 + <span>Search & filter indexed records</span> 59 + <span></span> 60 + </div> 61 + </div> 62 + </div> 63 + 64 + {/* Schema & Validation */} 65 + <div class="mb-12"> 66 + <h2 class="font-mono text-lg font-bold text-gray-800 mb-6"> 67 + Schema & Validation 68 + </h2> 69 + <div class="font-mono text-sm space-y-3"> 70 + <div class="flex justify-between"> 71 + <span>Import lexicon definitions</span> 72 + <span>$24.00</span> 73 + </div> 74 + <div class="flex justify-between"> 75 + <span>Autogenerated record-based XRPC routes</span> 76 + <span>$18.00</span> 77 + </div> 78 + <div class="flex justify-between"> 79 + <span>Built-in lexicon validation</span> 80 + <span>$22.00</span> 81 + </div> 82 + </div> 83 + </div> 84 + 85 + {/* TypeScript Client Generation */} 86 + <div class="mb-12"> 87 + <h2 class="font-mono text-lg font-bold text-gray-800 mb-6"> 88 + TypeScript Client Generation 89 + </h2> 90 + <div class="font-mono text-sm space-y-3"> 91 + <div class="flex justify-between"> 92 + <span>Type-safe client libraries</span> 93 + <div class="text-right"> 94 + <div>half $16.00 whole $32.00</div> 95 + </div> 96 + </div> 97 + <div class="flex justify-between"> 98 + <span>Auto CRUD method bindings</span> 99 + <div class="text-right"> 100 + <div>half $14.00 whole $28.00</div> 101 + </div> 102 + </div> 103 + <div class="flex justify-between"> 104 + <span>Advanced query capabilities</span> 105 + <div class="text-right"> 106 + <div>half $18.00 whole $36.00</div> 107 + </div> 108 + </div> 109 + <div class="flex justify-between"> 110 + <span>Lexicon-based type definitions</span> 111 + <div class="text-right"> 112 + <div>half $12.00 whole $24.00</div> 113 + </div> 114 + </div> 115 + </div> 116 + </div> 117 + 118 + {/* Infrastructure & Management */} 119 + <div class="mb-12"> 120 + <h2 class="font-mono text-lg font-bold text-gray-800 mb-6"> 121 + Infrastructure & Management 122 + </h2> 123 + <div class="font-mono text-sm space-y-3"> 124 + <div class="flex justify-between"> 125 + <span>Postgres-backed data indexing</span> 126 + <span>$42.00</span> 127 + </div> 128 + <div class="flex justify-between"> 129 + <span>Interactive OpenAPI spec</span> 130 + <span>$38.00</span> 131 + </div> 132 + <div class="flex justify-between"> 133 + <span>OAuth client management</span> 134 + <span>$35.00</span> 135 + </div> 136 + <div class="flex justify-between"> 137 + <span>Self-hostable deployment</span> 138 + <span>$28.00</span> 139 + </div> 140 + </div> 141 + </div> 142 + 143 + {/* CTA */} 144 + <div class="pt-8 border-t border-gray-200"> 145 + <div class="text-center"> 146 + <a 147 + href="/auth/login" 148 + class="font-mono text-sm bg-gray-800 text-white px-8 py-3 hover:bg-gray-700 transition-colors" 149 + > 150 + JOIN THE WAITLIST 151 + </a> 152 + </div> 153 + </div> 154 + </div> 155 + </div> 156 + </section> 157 + 158 + {/* Disclaimer */} 159 + <section class="bg-gray-50 py-4"> 160 + <div class="container mx-auto px-8"> 161 + <div class="max-w-4xl mx-auto"> 162 + <div class="font-mono text-xs text-gray-400 text-center"> 163 + Note: Menu prices are just for funsies • Join the waitlist for 164 + early access 165 + </div> 166 + </div> 167 + </div> 168 + </section> 169 + 170 + {/* Footer */} 171 + <footer class="bg-white border-t border-gray-200 py-8"> 172 + <div class="container mx-auto px-8"> 173 + <div class="max-w-4xl mx-auto"> 174 + <div class="font-mono text-xs text-gray-500 text-center"> 175 + © 2025 Slices • All rights reserved 176 + </div> 177 + </div> 178 + </div> 179 + </footer> 180 + </div> 181 + </Layout> 182 + ); 183 + }
+140
frontend/src/features/settings/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { renderHTML } from "../../utils/render.tsx"; 3 + import { hxRedirect } from "../../utils/htmx.ts"; 4 + import { withAuth, requireAuth } from "../../routes/middleware.ts"; 5 + import { atprotoClient } from "../../config.ts"; 6 + import { buildAtUri } from "../../utils/at-uri.ts"; 7 + import type { SocialSlicesActorProfile } from "../../client.ts"; 8 + import { SettingsPage } from "./templates/SettingsPage.tsx"; 9 + 10 + async function handleSettingsPage(req: Request): Promise<Response> { 11 + const context = await withAuth(req); 12 + 13 + if (!context.currentUser.isAuthenticated) { 14 + return Response.redirect(new URL("/login", req.url), 302); 15 + } 16 + 17 + const url = new URL(req.url); 18 + const updated = url.searchParams.get("updated"); 19 + const error = url.searchParams.get("error"); 20 + 21 + let profile: 22 + | { 23 + displayName?: string; 24 + description?: string; 25 + avatar?: string; 26 + } 27 + | undefined; 28 + 29 + try { 30 + const profileRecord = 31 + await atprotoClient.social.slices.actor.profile.getRecord({ 32 + uri: buildAtUri({ 33 + did: context.currentUser.sub!, 34 + collection: "social.slices.actor.profile", 35 + rkey: "self", 36 + }), 37 + }); 38 + if (profileRecord) { 39 + profile = { 40 + displayName: profileRecord.value.displayName, 41 + description: profileRecord.value.description, 42 + avatar: profileRecord.value.avatar?.toString(), 43 + }; 44 + } 45 + } catch (error) { 46 + console.error("Failed to fetch profile:", error); 47 + } 48 + 49 + return renderHTML( 50 + <SettingsPage 51 + profile={profile} 52 + currentUser={context.currentUser} 53 + updated={updated === "true"} 54 + error={error} 55 + /> 56 + ); 57 + } 58 + 59 + async function handleUpdateProfile(req: Request): Promise<Response> { 60 + const context = await withAuth(req); 61 + const authResponse = requireAuth(context); 62 + if (authResponse) return authResponse; 63 + 64 + try { 65 + const formData = await req.formData(); 66 + const displayName = formData.get("displayName") as string; 67 + const description = formData.get("description") as string; 68 + const avatarFile = formData.get("avatar") as File; 69 + 70 + const profileData: Partial<SocialSlicesActorProfile> = { 71 + displayName: displayName?.trim() || undefined, 72 + description: description?.trim() || undefined, 73 + createdAt: new Date().toISOString(), 74 + }; 75 + 76 + if (avatarFile && avatarFile.size > 0) { 77 + try { 78 + const arrayBuffer = await avatarFile.arrayBuffer(); 79 + 80 + const blobResult = await atprotoClient.uploadBlob({ 81 + data: arrayBuffer, 82 + mimeType: avatarFile.type, 83 + }); 84 + 85 + profileData.avatar = blobResult.blob; 86 + } catch (avatarError) { 87 + console.error("Failed to upload avatar:", avatarError); 88 + } 89 + } 90 + 91 + try { 92 + if (!context.currentUser.sub) { 93 + throw new Error("User DID (sub) is required for profile operations"); 94 + } 95 + 96 + const existingProfile = 97 + await atprotoClient.social.slices.actor.profile.getRecord({ 98 + uri: buildAtUri({ 99 + did: context.currentUser.sub, 100 + collection: "social.slices.actor.profile", 101 + rkey: "self", 102 + }), 103 + }); 104 + 105 + if (existingProfile) { 106 + await atprotoClient.social.slices.actor.profile.updateRecord("self", { 107 + ...profileData, 108 + createdAt: existingProfile.value.createdAt, 109 + }); 110 + } else { 111 + await atprotoClient.social.slices.actor.profile.createRecord( 112 + profileData, 113 + true 114 + ); 115 + } 116 + 117 + // Redirect back to settings page with success message 118 + return hxRedirect("/settings?updated=true"); 119 + } catch (profileError) { 120 + console.error("Profile update error:", profileError); 121 + return hxRedirect("/settings?error=update_failed"); 122 + } 123 + } catch (error) { 124 + console.error("Form processing error:", error); 125 + return hxRedirect("/settings?error=form_error"); 126 + } 127 + } 128 + 129 + export const settingsRoutes: Route[] = [ 130 + { 131 + method: "GET", 132 + pattern: new URLPattern({ pathname: "/settings" }), 133 + handler: handleSettingsPage, 134 + }, 135 + { 136 + method: "PUT", 137 + pattern: new URLPattern({ pathname: "/api/profile" }), 138 + handler: handleUpdateProfile, 139 + }, 140 + ];
+58
frontend/src/features/settings/templates/SettingsPage.tsx
··· 1 + import { Layout } from "../../../shared/fragments/Layout.tsx"; 2 + import { FlashMessage } from "../../../shared/fragments/FlashMessage.tsx"; 3 + import { SettingsForm } from "./fragments/SettingsForm.tsx"; 4 + import type { AuthenticatedUser } from "../../../routes/middleware.ts"; 5 + 6 + interface SettingsPageProps { 7 + profile?: { 8 + displayName?: string; 9 + description?: string; 10 + avatar?: string; 11 + }; 12 + updated?: boolean; 13 + error?: string | null; 14 + currentUser?: AuthenticatedUser; 15 + } 16 + 17 + export function SettingsPage({ 18 + profile, 19 + updated = false, 20 + error, 21 + currentUser, 22 + }: SettingsPageProps) { 23 + return ( 24 + <Layout title="Settings - Slice" currentUser={currentUser}> 25 + <div> 26 + <div className="mb-8"> 27 + <h1 className="text-3xl font-bold text-gray-900">Settings</h1> 28 + <p className="mt-2 text-gray-600"> 29 + Manage your profile information and preferences. 30 + </p> 31 + </div> 32 + 33 + {/* Flash Messages */} 34 + {updated && ( 35 + <FlashMessage 36 + type="success" 37 + message="Profile updated successfully!" 38 + /> 39 + )} 40 + 41 + {error && ( 42 + <FlashMessage 43 + type="error" 44 + message={ 45 + error === "update_failed" 46 + ? "Failed to update profile. Please try again." 47 + : error === "form_error" 48 + ? "Failed to process form data. Please try again." 49 + : "An error occurred." 50 + } 51 + /> 52 + )} 53 + 54 + <SettingsForm profile={profile} /> 55 + </div> 56 + </Layout> 57 + ); 58 + }
+76
frontend/src/features/settings/templates/fragments/SettingsForm.tsx
··· 1 + import { Input } from "../../../../shared/fragments/Input.tsx"; 2 + import { Textarea } from "../../../../shared/fragments/Textarea.tsx"; 3 + import { Button } from "../../../../shared/fragments/Button.tsx"; 4 + 5 + interface SettingsFormProps { 6 + profile?: { 7 + displayName?: string; 8 + description?: string; 9 + avatar?: string; 10 + }; 11 + } 12 + 13 + export function SettingsForm({ profile }: SettingsFormProps) { 14 + return ( 15 + <div className="bg-white rounded-lg shadow-md p-6"> 16 + <h2 className="text-xl font-semibold text-gray-800 mb-4"> 17 + Profile Settings 18 + </h2> 19 + 20 + <form 21 + hx-put="/api/profile" 22 + hx-target="#settings-result" 23 + hx-swap="innerHTML" 24 + hx-encoding="multipart/form-data" 25 + className="space-y-4" 26 + > 27 + <Input 28 + type="text" 29 + name="displayName" 30 + label="Display Name" 31 + defaultValue={profile?.displayName || ""} 32 + placeholder="Your display name" 33 + /> 34 + 35 + <Textarea 36 + name="description" 37 + label="Description" 38 + rows={4} 39 + placeholder="Free-form profile description text" 40 + defaultValue={profile?.description || ""} 41 + /> 42 + 43 + <div> 44 + <label className="block text-sm font-medium text-gray-700 mb-2"> 45 + Avatar 46 + </label> 47 + <input 48 + type="file" 49 + name="avatar" 50 + accept="image/*" 51 + className="block w-full border border-gray-300 rounded-md px-3 py-2" 52 + /> 53 + </div> 54 + 55 + <Button 56 + type="submit" 57 + variant="primary" 58 + hx-indicator="#loading-spinner" 59 + > 60 + <span 61 + className="htmx-indicator" 62 + id="loading-spinner" 63 + style="display:none;" 64 + > 65 + Saving... 66 + </span> 67 + <span>Save Profile</span> 68 + </Button> 69 + </form> 70 + 71 + <div id="settings-result" className="mt-4"> 72 + {/* Results will be loaded here via htmx */} 73 + </div> 74 + </div> 75 + ); 76 + }
+70
frontend/src/features/slices/api-docs/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { renderHTML } from "../../../utils/render.tsx"; 3 + import { withAuth } from "../../../routes/middleware.ts"; 4 + import { atprotoClient } from "../../../config.ts"; 5 + import { buildAtUri } from "../../../utils/at-uri.ts"; 6 + import { SliceApiDocsPage } from "./templates/SliceApiDocsPage.tsx"; 7 + 8 + async function handleSliceApiDocsPage( 9 + req: Request, 10 + params?: URLPatternResult 11 + ): Promise<Response> { 12 + const context = await withAuth(req); 13 + const sliceId = params?.pathname.groups.id; 14 + 15 + if (!sliceId) { 16 + return Response.redirect(new URL("/", req.url), 302); 17 + } 18 + 19 + // Get OAuth access token directly from OAuth client (clean separation) 20 + let accessToken: string | undefined; 21 + try { 22 + // Tokens are managed by @slices/oauth, not stored in sessions 23 + const tokens = await atprotoClient.oauth?.ensureValidToken(); 24 + accessToken = tokens?.accessToken; 25 + } catch (error) { 26 + console.log("Could not get OAuth token:", error); 27 + } 28 + 29 + // Get real slice data from AT Protocol 30 + let sliceData = { 31 + sliceId, 32 + sliceName: "Unknown Slice", 33 + accessToken, 34 + }; 35 + 36 + if (context.currentUser.isAuthenticated) { 37 + try { 38 + const sliceUri = buildAtUri({ 39 + did: context.currentUser.sub!, 40 + collection: "social.slices.slice", 41 + rkey: sliceId, 42 + }); 43 + 44 + const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ 45 + uri: sliceUri, 46 + }); 47 + 48 + sliceData = { 49 + sliceId, 50 + sliceName: sliceRecord.value.name, 51 + accessToken, 52 + }; 53 + } catch (error) { 54 + console.error("Failed to fetch slice data:", error); 55 + // Fall back to default data 56 + } 57 + } 58 + 59 + return renderHTML( 60 + <SliceApiDocsPage {...sliceData} currentUser={context.currentUser} /> 61 + ); 62 + } 63 + 64 + export const apiDocsRoutes: Route[] = [ 65 + { 66 + method: "GET", 67 + pattern: new URLPattern({ pathname: "/slices/:id/api-docs" }), 68 + handler: handleSliceApiDocsPage, 69 + }, 70 + ];
+114
frontend/src/features/slices/codegen/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 3 + import { atprotoClient } from "../../../config.ts"; 4 + import { getSliceClient } from "../../../utils/client.ts"; 5 + import { buildSliceUri } from "../../../utils/at-uri.ts"; 6 + import { renderHTML } from "../../../utils/render.tsx"; 7 + import { SliceCodegenPage } from "./templates/SliceCodegenPage.tsx"; 8 + import { CodegenResult } from "./templates/fragments/CodegenResult.tsx"; 9 + 10 + async function handleSliceCodegenPage( 11 + req: Request, 12 + params?: URLPatternResult 13 + ): Promise<Response> { 14 + const context = await withAuth(req); 15 + const sliceId = params?.pathname.groups.id; 16 + 17 + if (!sliceId) { 18 + return Response.redirect(new URL("/", req.url), 302); 19 + } 20 + 21 + if (!context.currentUser.isAuthenticated) { 22 + return Response.redirect(new URL("/login", req.url), 302); 23 + } 24 + 25 + let sliceData = { 26 + sliceId, 27 + sliceName: "Unknown Slice", 28 + }; 29 + 30 + try { 31 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 32 + const slice = await atprotoClient.social.slices.slice.getRecord({ 33 + uri: sliceUri, 34 + }); 35 + 36 + if (slice.value) { 37 + sliceData = { 38 + sliceId, 39 + sliceName: slice.value.name || "Unknown Slice", 40 + }; 41 + } 42 + } catch (error) { 43 + console.error("Failed to fetch slice:", error); 44 + } 45 + 46 + return renderHTML( 47 + <SliceCodegenPage {...sliceData} currentUser={context.currentUser} /> 48 + ); 49 + } 50 + 51 + async function handleSliceCodegen( 52 + req: Request, 53 + params?: URLPatternResult 54 + ): Promise<Response> { 55 + const context = await withAuth(req); 56 + const authResponse = requireAuth(context); 57 + if (authResponse) return authResponse; 58 + 59 + const sliceId = params?.pathname.groups.id; 60 + if (!sliceId) { 61 + const component = await CodegenResult({ 62 + success: false, 63 + error: "Invalid slice ID", 64 + }); 65 + return renderHTML(component, { status: 400 }); 66 + } 67 + 68 + try { 69 + // Parse form data 70 + const formData = await req.formData(); 71 + const target = formData.get("format") || "typescript"; 72 + 73 + // Construct the slice URI 74 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 75 + 76 + // Use the slice-specific client 77 + const sliceClient = getSliceClient(context, sliceId); 78 + 79 + // Call the codegen XRPC endpoint 80 + const result = await sliceClient.social.slices.slice.codegen({ 81 + target: target as string, 82 + slice: sliceUri, 83 + }); 84 + 85 + const component = await CodegenResult({ 86 + success: result.success, 87 + generatedCode: result.generatedCode, 88 + error: result.error, 89 + }); 90 + 91 + return renderHTML(component); 92 + } catch (error) { 93 + console.error("Codegen error:", error); 94 + const component = await CodegenResult({ 95 + success: false, 96 + error: `Error: ${error instanceof Error ? error.message : String(error)}`, 97 + }); 98 + 99 + return renderHTML(component); 100 + } 101 + } 102 + 103 + export const codegenRoutes: Route[] = [ 104 + { 105 + method: "GET", 106 + pattern: new URLPattern({ pathname: "/slices/:id/codegen" }), 107 + handler: handleSliceCodegenPage, 108 + }, 109 + { 110 + method: "POST", 111 + pattern: new URLPattern({ pathname: "/api/slices/:id/codegen" }), 112 + handler: handleSliceCodegen, 113 + }, 114 + ];
+201
frontend/src/features/slices/jetstream/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 3 + import { getSliceClient } from "../../../utils/client.ts"; 4 + import { atprotoClient } from "../../../config.ts"; 5 + import { renderHTML } from "../../../utils/render.tsx"; 6 + import { Layout } from "../../../shared/fragments/Layout.tsx"; 7 + import { JetstreamLogsPage } from "./templates/JetstreamLogsPage.tsx"; 8 + import { JetstreamLogs } from "./templates/fragments/JetstreamLogs.tsx"; 9 + import { JetstreamStatus } from "./templates/fragments/JetstreamStatus.tsx"; 10 + import type { LogEntry } from "../../../client.ts"; 11 + 12 + async function handleJetstreamLogs( 13 + req: Request, 14 + params?: URLPatternResult 15 + ): Promise<Response> { 16 + const context = await withAuth(req); 17 + const authResponse = requireAuth(context); 18 + if (authResponse) return authResponse; 19 + 20 + const sliceId = params?.pathname.groups.id; 21 + if (!sliceId) { 22 + return renderHTML( 23 + <div className="p-8 text-center text-red-600">❌ Invalid slice ID</div>, 24 + { status: 400 } 25 + ); 26 + } 27 + 28 + try { 29 + // Use the slice-specific client 30 + const sliceClient = getSliceClient(context, sliceId); 31 + 32 + // Get Jetstream logs 33 + const result = await sliceClient.social.slices.slice.getJetstreamLogs({ 34 + limit: 100, 35 + }); 36 + 37 + const logs = result?.logs || []; 38 + 39 + // Sort logs in descending order (newest first) 40 + const sortedLogs = logs.sort( 41 + (a, b) => 42 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 43 + ); 44 + 45 + // Render the log content 46 + return renderHTML(<JetstreamLogs logs={sortedLogs} />); 47 + } catch (error) { 48 + console.error("Failed to get Jetstream logs:", error); 49 + const errorMessage = error instanceof Error ? error.message : String(error); 50 + return renderHTML( 51 + <Layout title="Error"> 52 + <div className="max-w-6xl mx-auto"> 53 + <div className="flex items-center gap-4 mb-6"> 54 + <a 55 + href={`/slices/${sliceId}`} 56 + className="text-blue-600 hover:text-blue-800" 57 + > 58 + ← Back to Slice 59 + </a> 60 + <h1 className="text-2xl font-semibold text-gray-900"> 61 + ✈️ Jetstream Logs 62 + </h1> 63 + </div> 64 + <div className="p-8 text-center text-red-600"> 65 + ❌ Error loading Jetstream logs: {errorMessage} 66 + </div> 67 + </div> 68 + </Layout>, 69 + { status: 500 } 70 + ); 71 + } 72 + } 73 + 74 + async function handleJetstreamStatus( 75 + req: Request, 76 + _params?: URLPatternResult 77 + ): Promise<Response> { 78 + try { 79 + // Extract parameters from query 80 + const url = new URL(req.url); 81 + const sliceId = url.searchParams.get("sliceId"); 82 + const isCompact = url.searchParams.get("compact") === "true"; 83 + 84 + // Fetch jetstream status using the atproto client 85 + const data = await atprotoClient.social.slices.slice.getJetstreamStatus(); 86 + 87 + // Render compact version for logs page 88 + if (isCompact) { 89 + return renderHTML( 90 + <div className="inline-flex items-center gap-2 text-xs"> 91 + {data.connected ? ( 92 + <> 93 + <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> 94 + <span className="text-green-700">Jetstream Connected</span> 95 + </> 96 + ) : ( 97 + <> 98 + <div className="w-2 h-2 bg-red-500 rounded-full"></div> 99 + <span className="text-red-700">Jetstream Offline</span> 100 + </> 101 + )} 102 + </div> 103 + ); 104 + } 105 + 106 + // Render full version for main page 107 + return renderHTML( 108 + <JetstreamStatus 109 + connected={data.connected} 110 + status={data.status} 111 + error={data.error} 112 + sliceId={sliceId || undefined} 113 + /> 114 + ); 115 + } catch (error) { 116 + // Extract parameters for error case too 117 + const url = new URL(req.url); 118 + const sliceId = url.searchParams.get("sliceId"); 119 + const isCompact = url.searchParams.get("compact") === "true"; 120 + 121 + // Render compact error version 122 + if (isCompact) { 123 + return renderHTML( 124 + <div className="inline-flex items-center gap-2 text-xs"> 125 + <div className="w-2 h-2 bg-red-500 rounded-full"></div> 126 + <span className="text-red-700">Jetstream Offline</span> 127 + </div> 128 + ); 129 + } 130 + 131 + // Fallback to disconnected state on error for full version 132 + return renderHTML( 133 + <JetstreamStatus 134 + connected={false} 135 + status="Connection error" 136 + error={error instanceof Error ? error.message : "Unknown error"} 137 + sliceId={sliceId || undefined} 138 + /> 139 + ); 140 + } 141 + } 142 + 143 + async function handleJetstreamLogsPage( 144 + req: Request, 145 + params?: URLPatternResult 146 + ): Promise<Response> { 147 + const context = await withAuth(req); 148 + 149 + if (!context.currentUser.isAuthenticated) { 150 + return Response.redirect(new URL("/login", req.url), 302); 151 + } 152 + 153 + const sliceId = params?.pathname.groups.id; 154 + 155 + if (!sliceId) { 156 + return new Response("Invalid slice ID", { status: 400 }); 157 + } 158 + 159 + // Fetch Jetstream logs 160 + let logs: LogEntry[] = []; 161 + 162 + try { 163 + const sliceClient = getSliceClient(context, sliceId); 164 + 165 + const logsResult = await sliceClient.social.slices.slice.getJetstreamLogs({ 166 + limit: 100, 167 + }); 168 + logs = logsResult.logs.sort( 169 + (a, b) => 170 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 171 + ); 172 + } catch (error) { 173 + console.error("Failed to fetch Jetstream logs:", error); 174 + } 175 + 176 + return renderHTML( 177 + <JetstreamLogsPage 178 + logs={logs} 179 + sliceId={sliceId} 180 + currentUser={context.currentUser} 181 + /> 182 + ); 183 + } 184 + 185 + export const jetstreamRoutes: Route[] = [ 186 + { 187 + method: "GET", 188 + pattern: new URLPattern({ pathname: "/slices/:id/jetstream/logs" }), 189 + handler: handleJetstreamLogsPage, 190 + }, 191 + { 192 + method: "GET", 193 + pattern: new URLPattern({ pathname: "/api/jetstream/status" }), 194 + handler: handleJetstreamStatus, 195 + }, 196 + { 197 + method: "GET", 198 + pattern: new URLPattern({ pathname: "/api/slices/:id/jetstream/logs" }), 199 + handler: handleJetstreamLogs, 200 + }, 201 + ];
+17
frontend/src/features/slices/jetstream/templates/fragments/JetstreamLogs.tsx
··· 1 + import type { LogEntry } from "../../../../../client.ts"; 2 + import { formatTimestamp } from "../../../../../utils/time.ts"; 3 + import { LogViewer } from "../../../../../shared/fragments/LogViewer.tsx"; 4 + 5 + interface JetstreamLogsProps { 6 + logs: LogEntry[]; 7 + } 8 + 9 + export function JetstreamLogs({ logs }: JetstreamLogsProps) { 10 + return ( 11 + <LogViewer 12 + logs={logs} 13 + emptyMessage="No Jetstream logs available for this slice." 14 + formatTimestamp={formatTimestamp} 15 + /> 16 + ); 17 + }
+325
frontend/src/features/slices/lexicon/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { renderHTML } from "../../../utils/render.tsx"; 3 + import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 4 + import { getSliceClient } from "../../../utils/client.ts"; 5 + import { buildSliceUri, buildAtUri } from "../../../utils/at-uri.ts"; 6 + import { atprotoClient } from "../../../config.ts"; 7 + import { SliceLexiconPage } from "./templates/SliceLexiconPage.tsx"; 8 + import { EmptyLexiconState } from "./templates/fragments/EmptyLexiconState.tsx"; 9 + import { LexiconSuccessMessage } from "./templates/fragments/LexiconSuccessMessage.tsx"; 10 + import { LexiconErrorMessage } from "./templates/fragments/LexiconErrorMessage.tsx"; 11 + import { LexiconListItem } from "./templates/fragments/LexiconListItem.tsx"; 12 + import { LexiconViewModal } from "./templates/fragments/LexiconViewModal.tsx"; 13 + 14 + async function handleListLexicons( 15 + req: Request, 16 + params?: URLPatternResult 17 + ): Promise<Response> { 18 + const context = await withAuth(req); 19 + const authResponse = requireAuth(context); 20 + if (authResponse) return authResponse; 21 + 22 + const sliceId = params?.pathname.groups.id; 23 + if (!sliceId) { 24 + return new Response("Invalid slice ID", { status: 400 }); 25 + } 26 + 27 + try { 28 + const sliceClient = getSliceClient(context, sliceId); 29 + const lexiconRecords = await sliceClient.social.slices.lexicon.getRecords(); 30 + 31 + if (lexiconRecords.records.length === 0) { 32 + return renderHTML(<EmptyLexiconState />); 33 + } 34 + 35 + return renderHTML( 36 + <div className="space-y-0"> 37 + {lexiconRecords.records.map((record) => ( 38 + <LexiconListItem 39 + key={record.uri} 40 + nsid={record.value.nsid} 41 + uri={record.uri} 42 + createdAt={record.value.createdAt} 43 + sliceId={sliceId} 44 + /> 45 + ))} 46 + </div> 47 + ); 48 + } catch (error) { 49 + console.error("Failed to fetch lexicons:", error); 50 + return renderHTML( 51 + <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 52 + <p>Failed to load lexicons: {error}</p> 53 + </div>, 54 + { status: 500 } 55 + ); 56 + } 57 + } 58 + 59 + async function handleCreateLexicon(req: Request): Promise<Response> { 60 + const context = await withAuth(req); 61 + const authResponse = requireAuth(context); 62 + if (authResponse) return authResponse; 63 + 64 + try { 65 + const formData = await req.formData(); 66 + const lexiconJson = formData.get("lexicon_json") as string; 67 + 68 + if (!lexiconJson || lexiconJson.trim().length === 0) { 69 + return renderHTML( 70 + <LexiconErrorMessage error="Lexicon JSON is required" />, 71 + { status: 400 } 72 + ); 73 + } 74 + 75 + let lexiconData; 76 + try { 77 + lexiconData = JSON.parse(lexiconJson); 78 + } catch (parseError) { 79 + return renderHTML( 80 + <LexiconErrorMessage 81 + error={`Failed to parse lexicon JSON: ${parseError}`} 82 + /> 83 + ); 84 + } 85 + 86 + if (!lexiconData.id && !lexiconData.nsid) { 87 + return renderHTML( 88 + <LexiconErrorMessage error="Lexicon must have an 'id' field (e.g., 'com.example.myLexicon')" /> 89 + ); 90 + } 91 + 92 + if (!lexiconData.defs && !lexiconData.definitions) { 93 + return renderHTML( 94 + <LexiconErrorMessage error="Lexicon must have a 'defs' field containing the schema definitions" /> 95 + ); 96 + } 97 + 98 + try { 99 + const url = new URL(req.url); 100 + const pathParts = url.pathname.split("/"); 101 + let sliceId = "example"; 102 + 103 + if ( 104 + pathParts.length >= 4 && 105 + pathParts[1] === "api" && 106 + pathParts[2] === "slices" 107 + ) { 108 + sliceId = pathParts[3]; 109 + } 110 + 111 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 112 + 113 + const lexiconRecord = { 114 + nsid: lexiconData.id, 115 + definitions: JSON.stringify(lexiconData.defs || lexiconData), 116 + createdAt: new Date().toISOString(), 117 + slice: sliceUri, 118 + }; 119 + 120 + const sliceClient = getSliceClient(context, sliceId); 121 + const result = await sliceClient.social.slices.lexicon.createRecord( 122 + lexiconRecord 123 + ); 124 + 125 + return renderHTML( 126 + <LexiconSuccessMessage 127 + nsid={lexiconRecord.nsid} 128 + uri={result.uri} 129 + sliceId={sliceId} 130 + /> 131 + ); 132 + } catch (createError) { 133 + let errorMessage = `Failed to create lexicon: ${createError}`; 134 + 135 + if (createError instanceof Error) { 136 + try { 137 + const errorResponse = JSON.parse(createError.message); 138 + if ( 139 + errorResponse.error === "ValidationError" && 140 + errorResponse.message 141 + ) { 142 + errorMessage = errorResponse.message; 143 + } 144 + } catch { 145 + const errorStr = createError.message; 146 + if (errorStr.includes("Invalid JSON in definitions field")) { 147 + errorMessage = 148 + "The lexicon definitions contain invalid JSON. Please check your JSON syntax."; 149 + } else if (errorStr.includes("must be camelCase")) { 150 + errorMessage = 151 + 'Definition names must be camelCase (letters and numbers only). Examples: "main", "listView", "aspectRatio"'; 152 + } else if (errorStr.includes("missing required 'type' field")) { 153 + errorMessage = 154 + 'Each lexicon definition must have a "type" field. Valid types include: "record", "object", "string", "integer", "boolean", "array", "union", "ref", "blob", "bytes", "cid-link", "unknown"'; 155 + } else if (errorStr.includes("Lexicon validation failed")) { 156 + errorMessage = errorStr; 157 + } 158 + } 159 + } 160 + 161 + return renderHTML(<LexiconErrorMessage error={errorMessage} />); 162 + } 163 + } catch (error) { 164 + return renderHTML( 165 + <LexiconErrorMessage error={`Server error: ${error}`} />, 166 + { status: 500 } 167 + ); 168 + } 169 + } 170 + 171 + async function handleViewLexicon( 172 + req: Request, 173 + params?: URLPatternResult 174 + ): Promise<Response> { 175 + const context = await withAuth(req); 176 + const authResponse = requireAuth(context); 177 + if (authResponse) return authResponse; 178 + 179 + const sliceId = params?.pathname.groups.id; 180 + const rkey = params?.pathname.groups.rkey; 181 + if (!sliceId || !rkey) { 182 + return new Response("Invalid slice ID or lexicon key", { status: 400 }); 183 + } 184 + 185 + try { 186 + const sliceClient = getSliceClient(context, sliceId); 187 + const lexiconRecords = await sliceClient.social.slices.lexicon.getRecords(); 188 + 189 + const lexicon = lexiconRecords.records.find((record) => 190 + record.uri.endsWith(`/${rkey}`) 191 + ); 192 + 193 + if (!lexicon) { 194 + return new Response("Lexicon not found", { status: 404 }); 195 + } 196 + 197 + const component = await LexiconViewModal({ 198 + nsid: lexicon.value.nsid, 199 + definitions: lexicon.value.definitions, 200 + uri: lexicon.uri, 201 + createdAt: lexicon.indexedAt, 202 + }); 203 + 204 + return renderHTML(component); 205 + } catch (error) { 206 + console.error("Error viewing lexicon:", error); 207 + return new Response("Failed to load lexicon", { status: 500 }); 208 + } 209 + } 210 + 211 + async function handleDeleteLexicon( 212 + req: Request, 213 + params?: URLPatternResult 214 + ): Promise<Response> { 215 + const context = await withAuth(req); 216 + const authResponse = requireAuth(context); 217 + if (authResponse) return authResponse; 218 + 219 + const sliceId = params?.pathname.groups.id; 220 + const rkey = params?.pathname.groups.rkey; 221 + if (!sliceId || !rkey) { 222 + return new Response("Invalid slice ID or lexicon ID", { status: 400 }); 223 + } 224 + 225 + try { 226 + const sliceClient = getSliceClient(context, sliceId); 227 + await sliceClient.social.slices.lexicon.deleteRecord(rkey); 228 + 229 + const remainingLexicons = 230 + await sliceClient.social.slices.lexicon.getRecords(); 231 + 232 + if (remainingLexicons.records.length === 0) { 233 + return renderHTML(<EmptyLexiconState withPadding />, { 234 + headers: { 235 + "HX-Retarget": "#lexicon-list", 236 + }, 237 + }); 238 + } else { 239 + return new Response("", { 240 + status: 200, 241 + headers: { "content-type": "text/html" }, 242 + }); 243 + } 244 + } catch (error) { 245 + console.error("Failed to delete lexicon:", error); 246 + return renderHTML( 247 + <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 248 + <p>Failed to delete lexicon: {error}</p> 249 + </div>, 250 + { status: 500 } 251 + ); 252 + } 253 + } 254 + 255 + async function handleSliceLexiconPage( 256 + req: Request, 257 + params?: URLPatternResult 258 + ): Promise<Response> { 259 + const context = await withAuth(req); 260 + if (!context.currentUser.isAuthenticated) { 261 + return new Response("", { 262 + status: 302, 263 + headers: { location: "/login" }, 264 + }); 265 + } 266 + 267 + const sliceId = params?.pathname.groups.id; 268 + if (!sliceId) { 269 + return new Response("Invalid slice ID", { status: 400 }); 270 + } 271 + 272 + const sliceUri = buildAtUri({ 273 + did: context.currentUser.sub!, 274 + collection: "social.slices.slice", 275 + rkey: sliceId, 276 + }); 277 + 278 + let slice; 279 + try { 280 + slice = await atprotoClient.social.slices.slice.getRecord({ 281 + uri: sliceUri, 282 + }); 283 + } catch (error) { 284 + console.error("Error fetching slice:", error); 285 + return new Response("Slice not found", { status: 404 }); 286 + } 287 + 288 + return renderHTML( 289 + <SliceLexiconPage 290 + sliceName={slice.value.name} 291 + sliceId={sliceId} 292 + currentUser={context.currentUser} 293 + /> 294 + ); 295 + } 296 + 297 + export const lexiconRoutes: Route[] = [ 298 + { 299 + method: "GET", 300 + pattern: new URLPattern({ pathname: "/slices/:id/lexicon" }), 301 + handler: handleSliceLexiconPage, 302 + }, 303 + { 304 + method: "GET", 305 + pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/list" }), 306 + handler: handleListLexicons, 307 + }, 308 + { 309 + method: "POST", 310 + pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons" }), 311 + handler: handleCreateLexicon, 312 + }, 313 + { 314 + method: "GET", 315 + pattern: new URLPattern({ 316 + pathname: "/api/slices/:id/lexicons/:rkey/view", 317 + }), 318 + handler: handleViewLexicon, 319 + }, 320 + { 321 + method: "DELETE", 322 + pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/:rkey" }), 323 + handler: handleDeleteLexicon, 324 + }, 325 + ];
+28
frontend/src/features/slices/mod.ts
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { overviewRoutes } from "./overview/handlers.tsx"; 3 + import { settingsRoutes } from "./settings/handlers.tsx"; 4 + import { lexiconRoutes } from "./lexicon/handlers.tsx"; 5 + import { recordsRoutes } from "./records/handlers.tsx"; 6 + import { codegenRoutes } from "./codegen/handlers.tsx"; 7 + import { oauthRoutes } from "./oauth/handlers.tsx"; 8 + import { apiDocsRoutes } from "./api-docs/handlers.tsx"; 9 + import { syncRoutes } from "./sync/handlers.tsx"; 10 + import { syncLogsRoutes } from "./sync-logs/handlers.tsx"; 11 + import { jetstreamRoutes } from "./jetstream/handlers.tsx"; 12 + 13 + // Export individual route groups 14 + export { overviewRoutes, settingsRoutes, lexiconRoutes, recordsRoutes, codegenRoutes, oauthRoutes, apiDocsRoutes, syncRoutes, syncLogsRoutes, jetstreamRoutes }; 15 + 16 + // Export consolidated routes array for easy import 17 + export const sliceRoutes: Route[] = [ 18 + ...overviewRoutes, 19 + ...settingsRoutes, 20 + ...lexiconRoutes, 21 + ...recordsRoutes, 22 + ...codegenRoutes, 23 + ...oauthRoutes, 24 + ...apiDocsRoutes, 25 + ...syncRoutes, 26 + ...syncLogsRoutes, 27 + ...jetstreamRoutes, 28 + ];
+354
frontend/src/features/slices/oauth/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 3 + import { getSliceClient } from "../../../utils/client.ts"; 4 + import { buildSliceUri, buildAtUri } from "../../../utils/at-uri.ts"; 5 + import { atprotoClient } from "../../../config.ts"; 6 + import { renderHTML } from "../../../utils/render.tsx"; 7 + import { SliceOAuthPage } from "./templates/SliceOAuthPage.tsx"; 8 + import { OAuthClientModal } from "./templates/fragments/OAuthClientModal.tsx"; 9 + import { OAuthRegistrationResult } from "./templates/fragments/OAuthRegistrationResult.tsx"; 10 + import { OAuthDeleteResult } from "./templates/fragments/OAuthDeleteResult.tsx"; 11 + 12 + async function handleOAuthClientNew(req: Request): Promise<Response> { 13 + const context = await withAuth(req); 14 + const authResponse = requireAuth(context); 15 + if (authResponse) return authResponse; 16 + 17 + const url = new URL(req.url); 18 + const sliceId = url.pathname.split("/")[3]; 19 + 20 + try { 21 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 22 + 23 + return renderHTML( 24 + <OAuthClientModal 25 + sliceId={sliceId} 26 + sliceUri={sliceUri} 27 + mode="new" 28 + clientData={undefined} 29 + /> 30 + ); 31 + } catch (error) { 32 + console.error("Error showing new OAuth client modal:", error); 33 + return renderHTML( 34 + <div className="p-6"> 35 + <h2 className="text-xl font-semibold text-gray-800 mb-4">Error</h2> 36 + <p className="text-gray-600 mb-4"> 37 + Failed to load OAuth client form:{" "} 38 + {error instanceof Error ? error.message : String(error)} 39 + </p> 40 + <button 41 + type="button" 42 + _="on click set #modal-container's innerHTML to ''" 43 + className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition" 44 + > 45 + Close 46 + </button> 47 + </div>, 48 + { status: 500 } 49 + ); 50 + } 51 + } 52 + 53 + async function handleOAuthClientRegister(req: Request): Promise<Response> { 54 + const context = await withAuth(req); 55 + const authResponse = requireAuth(context); 56 + if (authResponse) return authResponse; 57 + 58 + const url = new URL(req.url); 59 + const sliceId = url.pathname.split("/")[3]; 60 + 61 + try { 62 + const formData = await req.formData(); 63 + const clientName = formData.get("clientName") as string; 64 + const redirectUrisText = formData.get("redirectUris") as string; 65 + const scope = formData.get("scope") as string; 66 + const clientUri = formData.get("clientUri") as string; 67 + const logoUri = formData.get("logoUri") as string; 68 + const tosUri = formData.get("tosUri") as string; 69 + const policyUri = formData.get("policyUri") as string; 70 + 71 + // Parse redirect URIs (split by lines and filter empty) 72 + const redirectUris = redirectUrisText 73 + .split("\n") 74 + .map((uri) => uri.trim()) 75 + .filter((uri) => uri.length > 0); 76 + 77 + // Register new OAuth client via backend API 78 + const sliceClient = getSliceClient(context, sliceId); 79 + const newClient = await sliceClient.social.slices.slice.createOAuthClient({ 80 + clientName, 81 + redirectUris, 82 + scope: scope || undefined, 83 + clientUri: clientUri || undefined, 84 + logoUri: logoUri || undefined, 85 + tosUri: tosUri || undefined, 86 + policyUri: policyUri || undefined, 87 + }); 88 + 89 + return renderHTML( 90 + <OAuthRegistrationResult 91 + success 92 + sliceId={sliceId} 93 + clientId={newClient.clientId} 94 + /> 95 + ); 96 + } catch (error) { 97 + console.error("Error registering OAuth client:", error); 98 + return renderHTML( 99 + <OAuthRegistrationResult 100 + success={false} 101 + error={error instanceof Error ? error.message : String(error)} 102 + sliceId={sliceId} 103 + />, 104 + { status: 500 } 105 + ); 106 + } 107 + } 108 + 109 + async function handleOAuthClientDelete(req: Request): Promise<Response> { 110 + const context = await withAuth(req); 111 + const authResponse = requireAuth(context); 112 + if (authResponse) return authResponse; 113 + 114 + const url = new URL(req.url); 115 + const pathParts = url.pathname.split("/"); 116 + const sliceId = pathParts[3]; 117 + const clientId = decodeURIComponent(pathParts[5]); 118 + 119 + try { 120 + // Delete OAuth client via backend API 121 + const sliceClient = getSliceClient(context, sliceId); 122 + await sliceClient.social.slices.slice.deleteOAuthClient(clientId); 123 + 124 + return renderHTML(<OAuthDeleteResult success />); 125 + } catch (error) { 126 + console.error("Error deleting OAuth client:", error); 127 + return renderHTML( 128 + <OAuthDeleteResult 129 + success={false} 130 + error={error instanceof Error ? error.message : String(error)} 131 + />, 132 + { status: 500 } 133 + ); 134 + } 135 + } 136 + 137 + async function handleOAuthClientView(req: Request): Promise<Response> { 138 + const context = await withAuth(req); 139 + const authResponse = requireAuth(context); 140 + if (authResponse) return authResponse; 141 + 142 + const url = new URL(req.url); 143 + const pathParts = url.pathname.split("/"); 144 + const sliceId = pathParts[3]; 145 + const clientId = decodeURIComponent(pathParts[5]); 146 + 147 + try { 148 + // Fetch OAuth client details via backend API 149 + const sliceClient = getSliceClient(context, sliceId); 150 + const clientsResponse = 151 + await sliceClient.social.slices.slice.getOAuthClients(); 152 + const clientData = clientsResponse.clients.find( 153 + (c) => c.clientId === clientId 154 + ); 155 + 156 + const sliceUri = buildAtUri({ 157 + did: context.currentUser.sub!, 158 + collection: "social.slices.slice", 159 + rkey: sliceId, 160 + }); 161 + 162 + return renderHTML( 163 + <OAuthClientModal 164 + sliceId={sliceId} 165 + sliceUri={sliceUri} 166 + mode="view" 167 + clientData={clientData} 168 + /> 169 + ); 170 + } catch (error) { 171 + console.error("Error fetching OAuth client:", error); 172 + return renderHTML( 173 + <div className="p-6"> 174 + <h2 className="text-xl font-semibold text-gray-800 mb-4">Error</h2> 175 + <p className="text-gray-600 mb-4"> 176 + Failed to load OAuth client details:{" "} 177 + {error instanceof Error ? error.message : String(error)} 178 + </p> 179 + <button 180 + type="button" 181 + _="on click set #modal-container's innerHTML to ''" 182 + className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition" 183 + > 184 + Close 185 + </button> 186 + </div>, 187 + { status: 500 } 188 + ); 189 + } 190 + } 191 + 192 + async function handleOAuthClientUpdate(req: Request): Promise<Response> { 193 + const context = await withAuth(req); 194 + const authResponse = requireAuth(context); 195 + if (authResponse) return authResponse; 196 + 197 + const url = new URL(req.url); 198 + const pathParts = url.pathname.split("/"); 199 + const sliceId = pathParts[3]; 200 + const clientId = decodeURIComponent(pathParts[5]); 201 + 202 + try { 203 + const formData = await req.formData(); 204 + const clientName = formData.get("clientName") as string; 205 + const redirectUrisText = formData.get("redirectUris") as string; 206 + const scope = formData.get("scope") as string; 207 + const clientUri = formData.get("clientUri") as string; 208 + const logoUri = formData.get("logoUri") as string; 209 + const tosUri = formData.get("tosUri") as string; 210 + const policyUri = formData.get("policyUri") as string; 211 + 212 + // Parse redirect URIs (split by lines and filter empty) 213 + const redirectUris = redirectUrisText 214 + .split("\n") 215 + .map((uri) => uri.trim()) 216 + .filter((uri) => uri.length > 0); 217 + 218 + // Update OAuth client via backend API 219 + const sliceClient = getSliceClient(context, sliceId); 220 + const updatedClient = 221 + await sliceClient.social.slices.slice.updateOAuthClient({ 222 + clientId, 223 + clientName: clientName || undefined, 224 + redirectUris: redirectUris.length > 0 ? redirectUris : undefined, 225 + scope: scope || undefined, 226 + clientUri: clientUri || undefined, 227 + logoUri: logoUri || undefined, 228 + tosUri: tosUri || undefined, 229 + policyUri: policyUri || undefined, 230 + }); 231 + 232 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 233 + return renderHTML( 234 + <OAuthClientModal 235 + sliceId={sliceId} 236 + sliceUri={sliceUri} 237 + mode="view" 238 + clientData={updatedClient} 239 + /> 240 + ); 241 + } catch (error) { 242 + console.error("Error updating OAuth client:", error); 243 + return renderHTML( 244 + <OAuthDeleteResult 245 + success={false} 246 + error={error instanceof Error ? error.message : String(error)} 247 + />, 248 + { status: 500 } 249 + ); 250 + } 251 + } 252 + 253 + async function handleSliceOAuthPage( 254 + req: Request, 255 + params?: URLPatternResult 256 + ): Promise<Response> { 257 + const context = await withAuth(req); 258 + if (!context.currentUser.isAuthenticated) { 259 + return new Response("", { 260 + status: 302, 261 + headers: { location: "/login" }, 262 + }); 263 + } 264 + 265 + const sliceId = params?.pathname.groups.id; 266 + if (!sliceId) { 267 + return new Response("Invalid slice ID", { status: 400 }); 268 + } 269 + 270 + const sliceUri = buildAtUri({ 271 + did: context.currentUser.sub!, 272 + collection: "social.slices.slice", 273 + rkey: sliceId, 274 + }); 275 + 276 + const sliceClient = getSliceClient(context, sliceId); 277 + 278 + let slice; 279 + try { 280 + slice = await atprotoClient.social.slices.slice.getRecord({ 281 + uri: sliceUri, 282 + }); 283 + } catch (error) { 284 + console.error("Error fetching slice:", error); 285 + return new Response("Slice not found", { status: 404 }); 286 + } 287 + 288 + // Try to fetch OAuth clients 289 + let clientsWithDetails: { 290 + clientId: string; 291 + createdAt: string; 292 + clientName?: string; 293 + redirectUris?: string[]; 294 + }[] = []; 295 + let errorMessage = null; 296 + 297 + try { 298 + const oauthClientsResponse = 299 + await sliceClient.social.slices.slice.getOAuthClients(); 300 + console.log("Fetched OAuth clients:", oauthClientsResponse.clients); 301 + clientsWithDetails = oauthClientsResponse.clients.map((client) => ({ 302 + clientId: client.clientId, 303 + createdAt: new Date().toISOString(), // Backend should provide this 304 + clientName: client.clientName, 305 + redirectUris: client.redirectUris, 306 + })); 307 + } catch (oauthError) { 308 + console.error("Error fetching OAuth clients:", oauthError); 309 + errorMessage = "Failed to fetch OAuth clients"; 310 + } 311 + 312 + return renderHTML( 313 + <SliceOAuthPage 314 + sliceName={slice.value.name} 315 + sliceId={sliceId} 316 + clients={clientsWithDetails} 317 + currentUser={context.currentUser} 318 + error={errorMessage} 319 + /> 320 + ); 321 + } 322 + 323 + export const oauthRoutes: Route[] = [ 324 + { 325 + method: "GET", 326 + pattern: new URLPattern({ pathname: "/slices/:id/oauth" }), 327 + handler: handleSliceOAuthPage, 328 + }, 329 + { 330 + method: "GET", 331 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/new" }), 332 + handler: handleOAuthClientNew, 333 + }, 334 + { 335 + method: "POST", 336 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/register" }), 337 + handler: handleOAuthClientRegister, 338 + }, 339 + { 340 + method: "GET", 341 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/view" }), 342 + handler: handleOAuthClientView, 343 + }, 344 + { 345 + method: "POST", 346 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/update" }), 347 + handler: handleOAuthClientUpdate, 348 + }, 349 + { 350 + method: "DELETE", 351 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri" }), 352 + handler: handleOAuthClientDelete, 353 + }, 354 + ];
+85
frontend/src/features/slices/oauth/templates/SliceOAuthPage.tsx
··· 1 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 + import { SliceTabs } from "../../shared/fragments/SliceTabs.tsx"; 3 + import { Button } from "../../../../shared/fragments/Button.tsx"; 4 + import { OAuthClientsList } from "./fragments/OAuthClientsList.tsx"; 5 + import { EmptyOAuthState } from "./fragments/EmptyOAuthState.tsx"; 6 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 7 + 8 + interface OAuthClient { 9 + clientId: string; 10 + createdAt: string; 11 + clientName?: string; 12 + redirectUris?: string[]; 13 + } 14 + 15 + interface SliceOAuthPageProps { 16 + sliceName?: string; 17 + sliceId?: string; 18 + clients?: OAuthClient[]; 19 + currentUser?: AuthenticatedUser; 20 + error?: string | null; 21 + success?: string | null; 22 + } 23 + 24 + export function SliceOAuthPage({ 25 + sliceName = "My Slice", 26 + sliceId = "example", 27 + clients = [], 28 + currentUser, 29 + error = null, 30 + success = null, 31 + }: SliceOAuthPageProps) { 32 + return ( 33 + <Layout title={`${sliceName} - OAuth Clients`} currentUser={currentUser}> 34 + <div> 35 + <div className="flex items-center justify-between mb-8"> 36 + <div className="flex items-center"> 37 + <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 38 + ← Back to Slices 39 + </a> 40 + <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 41 + </div> 42 + </div> 43 + 44 + <SliceTabs sliceId={sliceId} currentTab="oauth" /> 45 + 46 + {success && ( 47 + <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"> 48 + ✅ {success} 49 + </div> 50 + )} 51 + 52 + {error && ( 53 + <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> 54 + ❌ {error} 55 + </div> 56 + )} 57 + 58 + <div className="bg-white rounded-lg shadow-md p-6"> 59 + <div className="flex justify-between items-center mb-6"> 60 + <h2 className="text-2xl font-semibold text-gray-800"> 61 + OAuth Clients 62 + </h2> 63 + <Button 64 + type="button" 65 + variant="primary" 66 + hx-get={`/api/slices/${sliceId}/oauth/new`} 67 + hx-target="#modal-container" 68 + hx-swap="innerHTML" 69 + > 70 + Register New Client 71 + </Button> 72 + </div> 73 + 74 + {clients.length === 0 ? ( 75 + <EmptyOAuthState sliceId={sliceId} /> 76 + ) : ( 77 + <OAuthClientsList clients={clients} sliceId={sliceId} /> 78 + )} 79 + </div> 80 + 81 + <div id="modal-container"></div> 82 + </div> 83 + </Layout> 84 + ); 85 + }
+25
frontend/src/features/slices/oauth/templates/fragments/EmptyOAuthState.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + 3 + interface EmptyOAuthStateProps { 4 + sliceId: string; 5 + } 6 + 7 + export function EmptyOAuthState({ sliceId }: EmptyOAuthStateProps) { 8 + return ( 9 + <div className="text-center py-12"> 10 + <p className="text-gray-600 mb-4"> 11 + No OAuth clients registered for this slice. 12 + </p> 13 + <Button 14 + type="button" 15 + variant="ghost" 16 + hx-get={`/api/slices/${sliceId}/oauth/new`} 17 + hx-target="#modal-container" 18 + hx-swap="innerHTML" 19 + className="text-blue-600 hover:text-blue-800" 20 + > 21 + Register your first OAuth client 22 + </Button> 23 + </div> 24 + ); 25 + }
+246
frontend/src/features/slices/oauth/templates/fragments/OAuthClientModal.tsx
··· 1 + import { OAuthClientDetails } from "../../../../../client.ts"; 2 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 3 + import { Input } from "../../../../../shared/fragments/Input.tsx"; 4 + import { Textarea } from "../../../../../shared/fragments/Textarea.tsx"; 5 + 6 + interface OAuthClientModalProps { 7 + sliceId: string; 8 + sliceUri: string; 9 + mode: "new" | "view"; 10 + clientData?: OAuthClientDetails; 11 + } 12 + 13 + export function OAuthClientModal({ 14 + sliceId, 15 + sliceUri, 16 + mode, 17 + clientData, 18 + }: OAuthClientModalProps) { 19 + if (mode === "view" && clientData) { 20 + return ( 21 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 22 + <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 23 + <form 24 + hx-post={`/api/slices/${sliceId}/oauth/${encodeURIComponent(clientData.clientId)}/update`} 25 + hx-target="#modal-container" 26 + hx-swap="outerHTML" 27 + > 28 + <div className="flex justify-between items-start mb-4"> 29 + <h2 className="text-2xl font-semibold">OAuth Client Details</h2> 30 + <button 31 + type="button" 32 + _="on click set #modal-container's innerHTML to ''" 33 + className="text-gray-400 hover:text-gray-600" 34 + > 35 + 36 + </button> 37 + </div> 38 + 39 + <div className="space-y-4"> 40 + <div> 41 + <label className="block text-sm font-medium text-gray-700 mb-1"> 42 + Client ID 43 + </label> 44 + <div className="font-mono text-sm bg-gray-100 p-2 rounded border"> 45 + {clientData.clientId} 46 + </div> 47 + </div> 48 + 49 + {clientData.clientSecret && ( 50 + <div> 51 + <label className="block text-sm font-medium text-gray-700 mb-1"> 52 + Client Secret 53 + </label> 54 + <div className="font-mono text-sm bg-yellow-50 border border-yellow-200 p-2 rounded"> 55 + <div className="text-yellow-800 text-xs mb-1">⚠️ Save this secret - it won't be shown again</div> 56 + {clientData.clientSecret} 57 + </div> 58 + </div> 59 + )} 60 + 61 + <Input 62 + id="clientName" 63 + name="clientName" 64 + label="Client Name" 65 + required 66 + defaultValue={clientData.clientName} 67 + /> 68 + 69 + <div> 70 + <Textarea 71 + id="redirectUris" 72 + name="redirectUris" 73 + label="Redirect URIs" 74 + required 75 + rows={3} 76 + defaultValue={clientData.redirectUris.join('\n')} 77 + /> 78 + <p className="text-sm text-gray-500 mt-1"> 79 + Enter one redirect URI per line 80 + </p> 81 + </div> 82 + 83 + <Input 84 + id="scope" 85 + name="scope" 86 + label="Scope" 87 + defaultValue={clientData.scope || ''} 88 + placeholder="atproto:atproto" 89 + /> 90 + 91 + <Input 92 + type="url" 93 + id="clientUri" 94 + name="clientUri" 95 + label="Client URI" 96 + defaultValue={clientData.clientUri || ''} 97 + placeholder="https://example.com" 98 + /> 99 + 100 + <Input 101 + type="url" 102 + id="logoUri" 103 + name="logoUri" 104 + label="Logo URI" 105 + defaultValue={clientData.logoUri || ''} 106 + placeholder="https://example.com/logo.png" 107 + /> 108 + 109 + <Input 110 + type="url" 111 + id="tosUri" 112 + name="tosUri" 113 + label="Terms of Service URI" 114 + defaultValue={clientData.tosUri || ''} 115 + placeholder="https://example.com/terms" 116 + /> 117 + 118 + <Input 119 + type="url" 120 + id="policyUri" 121 + name="policyUri" 122 + label="Privacy Policy URI" 123 + defaultValue={clientData.policyUri || ''} 124 + placeholder="https://example.com/privacy" 125 + /> 126 + 127 + <div className="flex justify-end gap-3 mt-6"> 128 + <Button 129 + type="button" 130 + variant="secondary" 131 + _="on click set #modal-container's innerHTML to ''" 132 + > 133 + Cancel 134 + </Button> 135 + <Button type="submit" variant="primary"> 136 + Update Client 137 + </Button> 138 + </div> 139 + </div> 140 + </form> 141 + </div> 142 + </div> 143 + ); 144 + } 145 + 146 + return ( 147 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 148 + <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 149 + <form 150 + hx-post={`/api/slices/${sliceId}/oauth/register`} 151 + hx-target="#modal-container" 152 + hx-swap="outerHTML" 153 + > 154 + <input type="hidden" name="sliceUri" value={sliceUri} /> 155 + 156 + <div className="flex justify-between items-start mb-4"> 157 + <h2 className="text-2xl font-semibold">Register OAuth Client</h2> 158 + <button 159 + type="button" 160 + _="on click set #modal-container's innerHTML to ''" 161 + className="text-gray-400 hover:text-gray-600" 162 + > 163 + 164 + </button> 165 + </div> 166 + 167 + <div className="space-y-4"> 168 + <Input 169 + id="clientName" 170 + name="clientName" 171 + label="Client Name" 172 + required 173 + placeholder="My Application" 174 + /> 175 + 176 + <div> 177 + <Textarea 178 + id="redirectUris" 179 + name="redirectUris" 180 + label="Redirect URIs" 181 + required 182 + rows={3} 183 + placeholder="https://example.com/callback&#10;https://localhost:3000/callback" 184 + /> 185 + <p className="text-sm text-gray-500 mt-1"> 186 + Enter one redirect URI per line 187 + </p> 188 + </div> 189 + 190 + <Input 191 + id="scope" 192 + name="scope" 193 + label="Scope" 194 + placeholder="atproto:atproto" 195 + /> 196 + 197 + <Input 198 + type="url" 199 + id="clientUri" 200 + name="clientUri" 201 + label="Client URI" 202 + placeholder="https://example.com" 203 + /> 204 + 205 + <Input 206 + type="url" 207 + id="logoUri" 208 + name="logoUri" 209 + label="Logo URI" 210 + placeholder="https://example.com/logo.png" 211 + /> 212 + 213 + <Input 214 + type="url" 215 + id="tosUri" 216 + name="tosUri" 217 + label="Terms of Service URI" 218 + placeholder="https://example.com/terms" 219 + /> 220 + 221 + <Input 222 + type="url" 223 + id="policyUri" 224 + name="policyUri" 225 + label="Privacy Policy URI" 226 + placeholder="https://example.com/privacy" 227 + /> 228 + 229 + <div className="flex justify-end gap-3 mt-6"> 230 + <Button 231 + type="button" 232 + variant="secondary" 233 + _="on click set #modal-container's innerHTML to ''" 234 + > 235 + Cancel 236 + </Button> 237 + <Button type="submit" variant="primary"> 238 + Register Client 239 + </Button> 240 + </div> 241 + </div> 242 + </form> 243 + </div> 244 + </div> 245 + ); 246 + }
+98
frontend/src/features/slices/oauth/templates/fragments/OAuthClientsList.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + 3 + interface OAuthClient { 4 + clientId: string; 5 + createdAt: string; 6 + clientName?: string; 7 + redirectUris?: string[]; 8 + } 9 + 10 + interface OAuthClientsListProps { 11 + clients: OAuthClient[]; 12 + sliceId: string; 13 + } 14 + 15 + export function OAuthClientsList({ clients, sliceId }: OAuthClientsListProps) { 16 + return ( 17 + <div className="overflow-x-auto"> 18 + <table className="w-full"> 19 + <thead> 20 + <tr className="border-b"> 21 + <th className="text-left py-2 px-4">Client ID</th> 22 + <th className="text-left py-2 px-4">Name</th> 23 + <th className="text-left py-2 px-4">Redirect URIs</th> 24 + <th className="text-left py-2 px-4">Created</th> 25 + <th className="text-left py-2 px-4">Actions</th> 26 + </tr> 27 + </thead> 28 + <tbody> 29 + {clients.map((client) => ( 30 + <tr 31 + key={client.clientId} 32 + className="border-b hover:bg-gray-50" 33 + > 34 + <td className="py-3 px-4 font-mono text-sm"> 35 + {client.clientId} 36 + </td> 37 + <td className="py-3 px-4"> 38 + {client.clientName || "Loading..."} 39 + </td> 40 + <td className="py-3 px-4"> 41 + {client.redirectUris ? ( 42 + <div className="text-sm"> 43 + {client.redirectUris.slice(0, 2).map((uri, idx) => ( 44 + <div key={idx} className="truncate max-w-xs"> 45 + {uri} 46 + </div> 47 + ))} 48 + {client.redirectUris.length > 2 && ( 49 + <div className="text-gray-500"> 50 + +{client.redirectUris.length - 2} more 51 + </div> 52 + )} 53 + </div> 54 + ) : ( 55 + <span className="text-gray-400">Loading...</span> 56 + )} 57 + </td> 58 + <td className="py-3 px-4 text-sm text-gray-600"> 59 + {new Date(client.createdAt).toLocaleDateString()} 60 + </td> 61 + <td className="py-3 px-4"> 62 + <div className="flex gap-2"> 63 + <Button 64 + type="button" 65 + variant="ghost" 66 + size="sm" 67 + hx-get={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 68 + client.clientId 69 + )}/view`} 70 + hx-target="#modal-container" 71 + hx-swap="innerHTML" 72 + className="text-blue-600 hover:text-blue-800" 73 + > 74 + View 75 + </Button> 76 + <Button 77 + type="button" 78 + variant="ghost" 79 + size="sm" 80 + hx-delete={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 81 + client.clientId 82 + )}`} 83 + hx-confirm="Are you sure you want to delete this OAuth client?" 84 + hx-target="closest tr" 85 + hx-swap="outerHTML" 86 + className="text-red-600 hover:text-red-800" 87 + > 88 + Delete 89 + </Button> 90 + </div> 91 + </td> 92 + </tr> 93 + ))} 94 + </tbody> 95 + </table> 96 + </div> 97 + ); 98 + }
+18
frontend/src/features/slices/oauth/templates/fragments/OAuthDeleteResult.tsx
··· 1 + interface OAuthDeleteResultProps { 2 + success: boolean; 3 + error?: string; 4 + } 5 + 6 + export function OAuthDeleteResult({ success, error }: OAuthDeleteResultProps) { 7 + if (success) { 8 + return <></>; 9 + } 10 + 11 + return ( 12 + <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> 16 + </tr> 17 + ); 18 + }
+70
frontend/src/features/slices/oauth/templates/fragments/OAuthRegistrationResult.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + 3 + interface OAuthRegistrationResultProps { 4 + success: boolean; 5 + sliceId: string; 6 + clientId?: string; 7 + error?: string; 8 + } 9 + 10 + export function OAuthRegistrationResult({ 11 + success, 12 + sliceId, 13 + clientId, 14 + error, 15 + }: OAuthRegistrationResultProps) { 16 + if (success) { 17 + 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> 26 + {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> 31 + )} 32 + <div className="flex justify-end gap-3"> 33 + <Button 34 + type="button" 35 + variant="primary" 36 + hx-get={`/slices/${sliceId}/oauth`} 37 + hx-target="body" 38 + hx-swap="innerHTML" 39 + hx-push-url="true" 40 + > 41 + View OAuth Clients 42 + </Button> 43 + </div> 44 + </div> 45 + </div> 46 + ); 47 + } 48 + 49 + 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"> 59 + <Button 60 + type="button" 61 + variant="secondary" 62 + _="on click set #modal-container's innerHTML to ''" 63 + > 64 + Close 65 + </Button> 66 + </div> 67 + </div> 68 + </div> 69 + ); 70 + }
+77
frontend/src/features/slices/overview/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { withAuth } from "../../../routes/middleware.ts"; 3 + import { atprotoClient } from "../../../config.ts"; 4 + import { buildAtUri } from "../../../utils/at-uri.ts"; 5 + import { renderHTML } from "../../../utils/render.tsx"; 6 + import { SliceOverview } from "./templates/SliceOverview.tsx"; 7 + 8 + async function handleSliceOverview( 9 + req: Request, 10 + params?: URLPatternResult 11 + ): Promise<Response> { 12 + const context = await withAuth(req); 13 + const sliceId = params?.pathname.groups.id; 14 + 15 + if (!sliceId) { 16 + return Response.redirect(new URL("/", req.url), 302); 17 + } 18 + 19 + let sliceData = { 20 + sliceId, 21 + sliceName: "Unknown Slice", 22 + totalRecords: 0, 23 + totalActors: 0, 24 + totalLexicons: 0, 25 + collections: [] as Array<{ name: string; count: number; actors?: number }>, 26 + }; 27 + 28 + if (context.currentUser.isAuthenticated) { 29 + try { 30 + const sliceUri = buildAtUri({ 31 + did: context.currentUser.sub || "unknown", 32 + collection: "social.slices.slice", 33 + rkey: sliceId, 34 + }); 35 + 36 + const [sliceRecord, stats] = await Promise.all([ 37 + atprotoClient.social.slices.slice.getRecord({ uri: sliceUri }), 38 + atprotoClient.social.slices.slice.stats({ slice: sliceUri }), 39 + ]); 40 + 41 + const collections = stats.success 42 + ? stats.collectionStats.map((stat) => ({ 43 + name: stat.collection, 44 + count: stat.recordCount, 45 + actors: stat.uniqueActors, 46 + })) 47 + : []; 48 + 49 + sliceData = { 50 + sliceId, 51 + sliceName: sliceRecord.value.name, 52 + totalRecords: stats.success ? stats.totalRecords : 0, 53 + totalActors: stats.success ? stats.totalActors : 0, 54 + totalLexicons: stats.success ? stats.totalLexicons : 0, 55 + collections, 56 + }; 57 + } catch (error) { 58 + console.error("Failed to fetch slice data:", error); 59 + } 60 + } 61 + 62 + return renderHTML( 63 + <SliceOverview 64 + {...sliceData} 65 + currentTab="overview" 66 + currentUser={context.currentUser} 67 + /> 68 + ); 69 + } 70 + 71 + export const overviewRoutes: Route[] = [ 72 + { 73 + method: "GET", 74 + pattern: new URLPattern({ pathname: "/slices/:id" }), 75 + handler: handleSliceOverview, 76 + }, 77 + ];
+125
frontend/src/features/slices/records/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { withAuth } from "../../../routes/middleware.ts"; 3 + import { atprotoClient } from "../../../config.ts"; 4 + import { getSliceClient } from "../../../utils/client.ts"; 5 + import { buildAtUri } from "../../../utils/at-uri.ts"; 6 + import { renderHTML } from "../../../utils/render.tsx"; 7 + import { SliceRecordsPage } from "./templates/SliceRecordsPage.tsx"; 8 + import type { IndexedRecord } from "../../../client.ts"; 9 + 10 + async function handleSliceRecordsPage( 11 + req: Request, 12 + params?: URLPatternResult 13 + ): Promise<Response> { 14 + const context = await withAuth(req); 15 + const sliceId = params?.pathname.groups.id; 16 + 17 + if (!sliceId) { 18 + return Response.redirect(new URL("/", req.url), 302); 19 + } 20 + 21 + // Get real slice data from AT Protocol 22 + let sliceData = { 23 + sliceId, 24 + sliceName: "Unknown Slice", 25 + sliceDomain: "", 26 + totalRecords: 0, 27 + collections: [] as Array<{ name: string; count: number }>, 28 + }; 29 + 30 + if (context.currentUser.isAuthenticated) { 31 + try { 32 + const sliceUri = buildAtUri({ 33 + did: context.currentUser.sub ?? "unknown", 34 + collection: "social.slices.slice", 35 + rkey: sliceId, 36 + }); 37 + 38 + const [sliceRecord, stats] = await Promise.all([ 39 + atprotoClient.social.slices.slice.getRecord({ uri: sliceUri }), 40 + atprotoClient.social.slices.slice.stats({ slice: sliceUri }), 41 + ]); 42 + 43 + const collections = stats.success 44 + ? stats.collectionStats.map((stat) => ({ 45 + name: stat.collection, 46 + count: stat.recordCount, 47 + })) 48 + : []; 49 + 50 + sliceData = { 51 + sliceId, 52 + sliceName: sliceRecord.value.name, 53 + sliceDomain: sliceRecord.value.domain || "", 54 + totalRecords: stats.success ? stats.totalRecords : 0, 55 + collections, 56 + }; 57 + } catch (error) { 58 + console.error("Failed to fetch slice:", error); 59 + } 60 + } 61 + 62 + // Get URL parameters for collection, author, and search filtering 63 + const url = new URL(req.url); 64 + const selectedCollection = url.searchParams.get("collection") || ""; 65 + const selectedAuthor = url.searchParams.get("author") || ""; 66 + const searchQuery = url.searchParams.get("search") || ""; 67 + 68 + // Fetch real records if a collection is selected 69 + let records: Array<IndexedRecord & { pretty_value: string }> = []; 70 + 71 + if ( 72 + (selectedCollection || searchQuery) && 73 + sliceData.collections.length > 0 74 + ) { 75 + try { 76 + const sliceClient = getSliceClient(context, sliceId); 77 + const recordsResult = 78 + await sliceClient.social.slices.slice.getSliceRecords({ 79 + where: { 80 + ...(selectedCollection && { 81 + collection: { eq: selectedCollection }, 82 + }), 83 + ...(searchQuery && { json: { contains: searchQuery } }), 84 + ...(selectedAuthor && { did: { eq: selectedAuthor } }), 85 + }, 86 + limit: 20, 87 + }); 88 + 89 + if (recordsResult.success) { 90 + records = recordsResult.records.map((record) => ({ 91 + uri: record.uri, 92 + indexedAt: record.indexedAt, 93 + collection: record.collection, 94 + did: record.did, 95 + cid: record.cid, 96 + value: record.value, 97 + pretty_value: JSON.stringify(record.value, null, 2), 98 + })); 99 + } 100 + } catch (error) { 101 + console.error("Failed to fetch records:", error); 102 + } 103 + } 104 + 105 + const recordsData = { 106 + ...sliceData, 107 + records, 108 + collection: selectedCollection, 109 + author: selectedAuthor, 110 + search: searchQuery, 111 + availableCollections: sliceData.collections, 112 + }; 113 + 114 + return renderHTML( 115 + <SliceRecordsPage {...recordsData} currentUser={context.currentUser} /> 116 + ); 117 + } 118 + 119 + export const recordsRoutes: Route[] = [ 120 + { 121 + method: "GET", 122 + pattern: new URLPattern({ pathname: "/slices/:id/records" }), 123 + handler: handleSliceRecordsPage, 124 + }, 125 + ];
+73
frontend/src/features/slices/records/templates/SliceRecordsPage.tsx
··· 1 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 + import { SliceTabs } from "../../shared/fragments/SliceTabs.tsx"; 3 + import { EmptyRecordsState } from "./fragments/EmptyRecordsState.tsx"; 4 + import { RecordFilterForm } from "./fragments/RecordFilterForm.tsx"; 5 + import { RecordsList } from "./fragments/RecordsList.tsx"; 6 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 7 + import type { IndexedRecord } from "../../../../client.ts"; 8 + 9 + interface Record extends IndexedRecord { 10 + pretty_value?: string; 11 + } 12 + 13 + interface AvailableCollection { 14 + name: string; 15 + count: number; 16 + } 17 + 18 + interface SliceRecordsPageProps { 19 + records?: Record[]; 20 + availableCollections?: AvailableCollection[]; 21 + collection?: string; 22 + author?: string; 23 + search?: string; 24 + sliceName?: string; 25 + sliceId?: string; 26 + currentUser?: AuthenticatedUser; 27 + } 28 + 29 + export function SliceRecordsPage({ 30 + records = [], 31 + availableCollections = [], 32 + collection = "", 33 + author = "", 34 + search = "", 35 + sliceName = "My Slice", 36 + sliceId = "example", 37 + currentUser, 38 + }: SliceRecordsPageProps) { 39 + return ( 40 + <Layout title={`${sliceName} - Records`} currentUser={currentUser}> 41 + <div> 42 + <div className="flex items-center justify-between mb-8"> 43 + <div className="flex items-center"> 44 + <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 45 + ← Back to Slices 46 + </a> 47 + <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 48 + </div> 49 + </div> 50 + 51 + <SliceTabs sliceId={sliceId} currentTab="records" /> 52 + 53 + <RecordFilterForm 54 + availableCollections={availableCollections} 55 + collection={collection} 56 + author={author} 57 + search={search} 58 + /> 59 + 60 + {records.length > 0 ? ( 61 + <RecordsList records={records} /> 62 + ) : ( 63 + <EmptyRecordsState 64 + collection={collection} 65 + author={author} 66 + search={search} 67 + sliceId={sliceId} 68 + /> 69 + )} 70 + </div> 71 + </Layout> 72 + ); 73 + }
+46
frontend/src/features/slices/records/templates/fragments/EmptyRecordsState.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + 3 + interface EmptyRecordsStateProps { 4 + collection?: string; 5 + author?: string; 6 + search?: string; 7 + sliceId: string; 8 + } 9 + 10 + export function EmptyRecordsState({ 11 + collection, 12 + author, 13 + search, 14 + sliceId, 15 + }: EmptyRecordsStateProps) { 16 + return ( 17 + <div className="bg-white rounded-lg shadow-md p-8 text-center"> 18 + <div className="text-gray-400 mb-4"> 19 + <svg 20 + className="mx-auto h-16 w-16" 21 + fill="none" 22 + viewBox="0 0 24 24" 23 + stroke="currentColor" 24 + > 25 + <path 26 + strokeLinecap="round" 27 + strokeLinejoin="round" 28 + strokeWidth={1} 29 + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 30 + /> 31 + </svg> 32 + </div> 33 + <h3 className="text-lg font-medium text-gray-900 mb-2"> 34 + No records found 35 + </h3> 36 + <p className="text-gray-500 mb-6"> 37 + {collection || author || search 38 + ? "Try adjusting your filters or search terms, or sync some data first." 39 + : "Start by syncing some AT Protocol collections."} 40 + </p> 41 + <Button href={`/slices/${sliceId}/sync`} variant="primary"> 42 + Go to Sync 43 + </Button> 44 + </div> 45 + ); 46 + }
+78
frontend/src/features/slices/records/templates/fragments/RecordFilterForm.tsx
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + import { Input } from "../../../../../shared/fragments/Input.tsx"; 3 + import { Select } from "../../../../../shared/fragments/Select.tsx"; 4 + 5 + interface AvailableCollection { 6 + name: string; 7 + count: number; 8 + } 9 + 10 + interface RecordFilterFormProps { 11 + availableCollections: AvailableCollection[]; 12 + collection: string; 13 + author: string; 14 + search: string; 15 + } 16 + 17 + export function RecordFilterForm({ 18 + availableCollections, 19 + collection, 20 + author, 21 + search, 22 + }: RecordFilterFormProps) { 23 + return ( 24 + <div className="bg-white rounded-lg shadow-md p-6 mb-6"> 25 + <div className="flex justify-between items-center mb-4"> 26 + <h2 className="text-xl font-semibold">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> 51 + 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> 74 + </div> 75 + </form> 76 + </div> 77 + ); 78 + }
+79
frontend/src/features/slices/records/templates/fragments/RecordsList.tsx
··· 1 + import type { IndexedRecord } from "../../../../../client.ts"; 2 + 3 + interface Record extends IndexedRecord { 4 + pretty_value?: string; 5 + } 6 + 7 + interface RecordsListProps { 8 + records: Record[]; 9 + } 10 + 11 + export function RecordsList({ records }: RecordsListProps) { 12 + return ( 13 + <div className="bg-white rounded-lg shadow-md"> 14 + <div className="px-6 py-4 border-b border-gray-200"> 15 + <h2 className="text-lg font-semibold"> 16 + Records ({records.length}) 17 + </h2> 18 + </div> 19 + <div className="divide-y divide-gray-200"> 20 + {records.map((record) => ( 21 + <div key={record.uri} className="p-6"> 22 + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> 23 + <div> 24 + <h3 className="text-lg font-medium text-gray-900 mb-2"> 25 + Metadata 26 + </h3> 27 + <dl className="grid grid-cols-1 gap-x-4 gap-y-2 text-sm"> 28 + <div className="grid grid-cols-3 gap-4"> 29 + <dt className="font-medium text-gray-500">URI:</dt> 30 + <dd className="col-span-2 text-gray-900 break-all"> 31 + {record.uri} 32 + </dd> 33 + </div> 34 + <div className="grid grid-cols-3 gap-4"> 35 + <dt className="font-medium text-gray-500"> 36 + Collection: 37 + </dt> 38 + <dd className="col-span-2 text-gray-900"> 39 + {record.collection} 40 + </dd> 41 + </div> 42 + <div className="grid grid-cols-3 gap-4"> 43 + <dt className="font-medium text-gray-500">DID:</dt> 44 + <dd className="col-span-2 text-gray-900 break-all"> 45 + {record.did} 46 + </dd> 47 + </div> 48 + <div className="grid grid-cols-3 gap-4"> 49 + <dt className="font-medium text-gray-500">CID:</dt> 50 + <dd className="col-span-2 text-gray-900 break-all"> 51 + {record.cid} 52 + </dd> 53 + </div> 54 + <div className="grid grid-cols-3 gap-4"> 55 + <dt className="font-medium text-gray-500"> 56 + Indexed: 57 + </dt> 58 + <dd className="col-span-2 text-gray-900"> 59 + {new Date(record.indexedAt).toLocaleString()} 60 + </dd> 61 + </div> 62 + </dl> 63 + </div> 64 + <div> 65 + <h3 className="text-lg font-medium text-gray-900 mb-2"> 66 + Record Data 67 + </h3> 68 + <pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64"> 69 + {record.pretty_value || 70 + JSON.stringify(record.value, null, 2)} 71 + </pre> 72 + </div> 73 + </div> 74 + </div> 75 + ))} 76 + </div> 77 + </div> 78 + ); 79 + }
+152
frontend/src/features/slices/settings/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { withAuth } from "../../../routes/middleware.ts"; 3 + import { atprotoClient } from "../../../config.ts"; 4 + import { buildSliceUri } from "../../../utils/at-uri.ts"; 5 + import { renderHTML } from "../../../utils/render.tsx"; 6 + import { hxRedirect } from "../../../utils/htmx.ts"; 7 + import { SliceSettings } from "./templates/SliceSettings.tsx"; 8 + 9 + async function handleSliceSettingsPage( 10 + req: Request, 11 + params?: URLPatternResult 12 + ): Promise<Response> { 13 + const context = await withAuth(req); 14 + const sliceId = params?.pathname.groups.id; 15 + 16 + if (!sliceId) { 17 + return Response.redirect(new URL("/", req.url), 302); 18 + } 19 + 20 + if (!context.currentUser.isAuthenticated) { 21 + return Response.redirect(new URL("/login", req.url), 302); 22 + } 23 + 24 + const url = new URL(req.url); 25 + const updated = url.searchParams.get("updated"); 26 + const error = url.searchParams.get("error"); 27 + 28 + // Get real slice data from AT Protocol 29 + let sliceData = { 30 + sliceId, 31 + sliceName: "Unknown Slice", 32 + sliceDomain: "", 33 + }; 34 + 35 + try { 36 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 37 + const slice = await atprotoClient.social.slices.slice.getRecord({ 38 + uri: sliceUri, 39 + }); 40 + 41 + if (slice.value) { 42 + sliceData = { 43 + sliceId, 44 + sliceName: slice.value.name || "Unknown Slice", 45 + sliceDomain: slice.value.domain || "", 46 + }; 47 + } 48 + } catch (error) { 49 + console.error("Failed to fetch slice:", error); 50 + } 51 + 52 + return renderHTML( 53 + <SliceSettings 54 + {...sliceData} 55 + updated={updated === "true"} 56 + error={error} 57 + currentUser={context.currentUser} 58 + /> 59 + ); 60 + } 61 + 62 + async function handleUpdateSliceSettings( 63 + req: Request, 64 + params?: URLPatternResult 65 + ): Promise<Response> { 66 + const context = await withAuth(req); 67 + const sliceId = params?.pathname.groups.id; 68 + 69 + if (!sliceId) { 70 + return new Response("Slice ID is required", { status: 400 }); 71 + } 72 + 73 + if (!context.currentUser.isAuthenticated) { 74 + return new Response("Unauthorized", { status: 401 }); 75 + } 76 + 77 + try { 78 + const formData = await req.formData(); 79 + const name = formData.get("name") as string; 80 + const domain = formData.get("domain") as string; 81 + 82 + if (!name || name.trim().length === 0) { 83 + return new Response("Name is required", { status: 400 }); 84 + } 85 + 86 + if (!domain || domain.trim().length === 0) { 87 + return new Response("Domain is required", { status: 400 }); 88 + } 89 + 90 + // Construct the URI for this slice 91 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 92 + 93 + // Get the current record first 94 + const currentRecord = await atprotoClient.social.slices.slice.getRecord({ 95 + uri: sliceUri, 96 + }); 97 + 98 + // Update the record with new name and domain 99 + await atprotoClient.social.slices.slice.updateRecord(sliceId, { 100 + ...currentRecord.value, 101 + name: name.trim(), 102 + domain: domain.trim(), 103 + }); 104 + 105 + return hxRedirect(`/slices/${sliceId}/settings?updated=true`); 106 + } catch (_error) { 107 + return hxRedirect(`/slices/${sliceId}/settings?error=update_failed`); 108 + } 109 + } 110 + 111 + async function handleDeleteSlice( 112 + req: Request, 113 + params?: URLPatternResult 114 + ): Promise<Response> { 115 + const context = await withAuth(req); 116 + const sliceId = params?.pathname.groups.id; 117 + 118 + if (!sliceId) { 119 + return new Response("Slice ID is required", { status: 400 }); 120 + } 121 + 122 + if (!context.currentUser.isAuthenticated) { 123 + return new Response("Unauthorized", { status: 401 }); 124 + } 125 + 126 + try { 127 + // Delete the slice record from AT Protocol 128 + await atprotoClient.social.slices.slice.deleteRecord(sliceId); 129 + 130 + return hxRedirect("/"); 131 + } catch (_error) { 132 + return new Response("Failed to delete slice", { status: 500 }); 133 + } 134 + } 135 + 136 + export const settingsRoutes: Route[] = [ 137 + { 138 + method: "GET", 139 + pattern: new URLPattern({ pathname: "/slices/:id/settings" }), 140 + handler: handleSliceSettingsPage, 141 + }, 142 + { 143 + method: "PUT", 144 + pattern: new URLPattern({ pathname: "/api/slices/:id/settings" }), 145 + handler: handleUpdateSliceSettings, 146 + }, 147 + { 148 + method: "DELETE", 149 + pattern: new URLPattern({ pathname: "/api/slices/:id" }), 150 + handler: handleDeleteSlice, 151 + }, 152 + ];
+109
frontend/src/features/slices/sync-logs/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { renderHTML } from "../../../utils/render.tsx"; 3 + import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 4 + import { getSliceClient } from "../../../utils/client.ts"; 5 + import { buildAtUri } from "../../../utils/at-uri.ts"; 6 + import { SyncJobLogsPage } from "./templates/SyncJobLogsPage.tsx"; 7 + import { SyncJobLogs } from "./templates/SyncJobLogs.tsx"; 8 + 9 + async function handleSyncJobLogsPage( 10 + req: Request, 11 + params?: URLPatternResult 12 + ): Promise<Response> { 13 + const context = await withAuth(req); 14 + 15 + if (!context.currentUser.isAuthenticated) { 16 + return Response.redirect(new URL("/login", req.url), 302); 17 + } 18 + 19 + const sliceId = params?.pathname.groups.id; 20 + const jobId = params?.pathname.groups.jobId; 21 + 22 + if (!sliceId || !jobId) { 23 + return new Response("Invalid slice ID or job ID", { status: 400 }); 24 + } 25 + 26 + // Get slice details to pass slice name 27 + let slice: { name: string } = { name: "Unknown Slice" }; 28 + try { 29 + const sliceClient = getSliceClient(context, sliceId); 30 + const sliceRecord = await sliceClient.social.slices.slice.getRecord({ 31 + uri: buildAtUri({ 32 + did: context.currentUser.sub!, 33 + collection: "social.slices.slice", 34 + rkey: sliceId, 35 + }), 36 + }); 37 + if (sliceRecord) { 38 + slice = { name: sliceRecord.value.name }; 39 + } 40 + } catch (error) { 41 + console.error("Failed to fetch slice:", error); 42 + } 43 + 44 + return renderHTML( 45 + <SyncJobLogsPage 46 + sliceName={slice.name} 47 + sliceId={sliceId} 48 + jobId={jobId} 49 + currentUser={context.currentUser} 50 + /> 51 + ); 52 + } 53 + 54 + async function handleSyncJobLogs( 55 + req: Request, 56 + params?: URLPatternResult 57 + ): Promise<Response> { 58 + const context = await withAuth(req); 59 + const authResponse = requireAuth(context); 60 + if (authResponse) return authResponse; 61 + 62 + const sliceId = params?.pathname.groups.id; 63 + const jobId = params?.pathname.groups.jobId; 64 + 65 + if (!sliceId || !jobId) { 66 + return renderHTML( 67 + <div className="p-8 text-center text-red-600"> 68 + Invalid slice ID or job ID 69 + </div>, 70 + { status: 400 } 71 + ); 72 + } 73 + 74 + try { 75 + const sliceClient = getSliceClient(context, sliceId); 76 + const logsResponse = await sliceClient.social.slices.slice.getJobLogs({ 77 + jobId, 78 + }); 79 + 80 + if (logsResponse.logs && Array.isArray(logsResponse.logs)) { 81 + return renderHTML(<SyncJobLogs logs={logsResponse.logs} />); 82 + } 83 + 84 + return renderHTML( 85 + <div className="p-8 text-center text-gray-600">No logs available</div> 86 + ); 87 + } catch (error) { 88 + console.error("Failed to get sync job logs:", error); 89 + const errorMessage = error instanceof Error ? error.message : String(error); 90 + return renderHTML( 91 + <div className="p-8 text-center text-red-600"> 92 + Failed to load logs: {errorMessage} 93 + </div> 94 + ); 95 + } 96 + } 97 + 98 + export const syncLogsRoutes: Route[] = [ 99 + { 100 + method: "GET", 101 + pattern: new URLPattern({ pathname: "/slices/:id/sync/logs/:jobId" }), 102 + handler: handleSyncJobLogsPage, 103 + }, 104 + { 105 + method: "GET", 106 + pattern: new URLPattern({ pathname: "/api/slices/:id/sync/logs/:jobId" }), 107 + handler: handleSyncJobLogs, 108 + }, 109 + ];
+15
frontend/src/features/slices/sync-logs/templates/SyncJobLogs.tsx
··· 1 + import type { LogEntry } from "../../../../client.ts"; 2 + import { LogViewer } from "../../../../shared/fragments/LogViewer.tsx"; 3 + 4 + interface SyncJobLogsProps { 5 + logs: LogEntry[]; 6 + } 7 + 8 + export function SyncJobLogs({ logs }: SyncJobLogsProps) { 9 + return ( 10 + <LogViewer 11 + logs={logs} 12 + emptyMessage="No logs available for this sync job." 13 + /> 14 + ); 15 + }
+218
frontend/src/features/slices/sync/handlers.tsx
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { renderHTML } from "../../../utils/render.tsx"; 3 + import { withAuth, requireAuth } from "../../../routes/middleware.ts"; 4 + import { getSliceClient } from "../../../utils/client.ts"; 5 + import { buildSliceUri, buildAtUri } from "../../../utils/at-uri.ts"; 6 + import { atprotoClient } from "../../../config.ts"; 7 + import { SliceSyncPage } from "./templates/SliceSyncPage.tsx"; 8 + import { SyncResult } from "./templates/fragments/SyncResult.tsx"; 9 + import { JobHistory } from "./templates/fragments/JobHistory.tsx"; 10 + 11 + async function handleSliceSync( 12 + req: Request, 13 + params?: URLPatternResult 14 + ): Promise<Response> { 15 + const context = await withAuth(req); 16 + const authResponse = requireAuth(context); 17 + if (authResponse) return authResponse; 18 + 19 + const sliceId = params?.pathname.groups.id; 20 + 21 + if (!sliceId) { 22 + return renderHTML( 23 + <SyncResult success={false} error="Invalid slice ID" /> 24 + ); 25 + } 26 + 27 + try { 28 + const formData = await req.formData(); 29 + const collectionsText = (formData.get("collections") as string) || ""; 30 + const externalCollectionsText = 31 + (formData.get("external_collections") as string) || ""; 32 + const reposText = (formData.get("repos") as string) || ""; 33 + 34 + const collections = collectionsText 35 + .split("\n") 36 + .map((line) => line.trim()) 37 + .filter((line) => line.length > 0); 38 + 39 + const externalCollections = externalCollectionsText 40 + .split("\n") 41 + .map((line) => line.trim()) 42 + .filter((line) => line.length > 0); 43 + 44 + const repos = reposText 45 + .split("\n") 46 + .map((line) => line.trim()) 47 + .filter((line) => line.length > 0); 48 + 49 + if (collections.length === 0 && externalCollections.length === 0) { 50 + return renderHTML( 51 + <SyncResult 52 + success={false} 53 + error="Please specify at least one collection (primary or external) to sync" 54 + /> 55 + ); 56 + } 57 + 58 + const sliceClient = getSliceClient(context, sliceId); 59 + const syncJobResponse = await sliceClient.social.slices.slice.startSync({ 60 + collections: collections.length > 0 ? collections : undefined, 61 + externalCollections: 62 + externalCollections.length > 0 ? externalCollections : undefined, 63 + repos: repos.length > 0 ? repos : undefined, 64 + }); 65 + 66 + return renderHTML( 67 + <SyncResult 68 + success={syncJobResponse.success} 69 + message={ 70 + syncJobResponse.success 71 + ? `Sync job started successfully. Job ID: ${syncJobResponse.jobId}` 72 + : syncJobResponse.message 73 + } 74 + jobId={syncJobResponse.jobId} 75 + collectionsCount={collections.length + externalCollections.length} 76 + error={syncJobResponse.success ? undefined : syncJobResponse.message} 77 + /> 78 + ); 79 + } catch (error) { 80 + console.error("Failed to start sync:", error); 81 + const errorMessage = error instanceof Error ? error.message : String(error); 82 + return renderHTML(<SyncResult success={false} error={errorMessage} />); 83 + } 84 + } 85 + 86 + async function handleJobHistory( 87 + req: Request, 88 + params?: URLPatternResult 89 + ): Promise<Response> { 90 + const context = await withAuth(req); 91 + const authResponse = requireAuth(context); 92 + if (authResponse) return authResponse; 93 + 94 + const sliceId = params?.pathname.groups.id; 95 + 96 + if (!sliceId) { 97 + return renderHTML( 98 + <div className="p-8 text-center text-red-600">Invalid slice ID</div>, 99 + { status: 400 } 100 + ); 101 + } 102 + 103 + try { 104 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 105 + const sliceClient = getSliceClient(context, sliceId); 106 + const jobsResponse = await sliceClient.social.slices.slice.getJobHistory({ 107 + userDid: context.currentUser.sub!, 108 + sliceUri: sliceUri, 109 + limit: 10, 110 + }); 111 + 112 + return renderHTML( 113 + <JobHistory jobs={jobsResponse || []} sliceId={sliceId} /> 114 + ); 115 + } catch (error) { 116 + console.error("Failed to fetch job history:", error); 117 + return renderHTML( 118 + <div className="p-8 text-center text-red-600"> 119 + Failed to load job history 120 + </div> 121 + ); 122 + } 123 + } 124 + 125 + async function handleSliceSyncPage( 126 + req: Request, 127 + params?: URLPatternResult 128 + ): Promise<Response> { 129 + const context = await withAuth(req); 130 + if (!context.currentUser.isAuthenticated) { 131 + return new Response("", { 132 + status: 302, 133 + headers: { location: "/login" }, 134 + }); 135 + } 136 + 137 + const sliceId = params?.pathname.groups.id; 138 + if (!sliceId) { 139 + return new Response("Invalid slice ID", { status: 400 }); 140 + } 141 + 142 + // Get the slice record 143 + const sliceUri = buildAtUri({ 144 + did: context.currentUser.sub!, 145 + collection: "social.slices.slice", 146 + rkey: sliceId, 147 + }); 148 + 149 + const sliceClient = getSliceClient(context, sliceId); 150 + 151 + let slice; 152 + const collections: string[] = []; 153 + const externalCollections: string[] = []; 154 + 155 + try { 156 + slice = await atprotoClient.social.slices.slice.getRecord({ 157 + uri: sliceUri, 158 + }); 159 + 160 + // Get all lexicons and filter by record types 161 + try { 162 + const lexiconsResponse = 163 + await sliceClient.social.slices.lexicon.getRecords(); 164 + const recordLexicons = lexiconsResponse.records.filter((lexicon) => { 165 + try { 166 + const definitions = JSON.parse(lexicon.value.definitions); 167 + return definitions.main.type === "record"; 168 + } catch { 169 + return false; 170 + } 171 + }); 172 + 173 + // Categorize by domain - primary collections match slice domain, external don't 174 + const sliceDomain = slice.value.domain; 175 + 176 + recordLexicons.forEach((lexicon) => { 177 + if (lexicon.value.nsid.startsWith(sliceDomain)) { 178 + collections.push(lexicon.value.nsid); 179 + } else { 180 + externalCollections.push(lexicon.value.nsid); 181 + } 182 + }); 183 + } catch (error) { 184 + console.error("Error fetching lexicons:", error); 185 + } 186 + } catch (error) { 187 + console.error("Error fetching slice:", error); 188 + return new Response("Slice not found", { status: 404 }); 189 + } 190 + 191 + return renderHTML( 192 + <SliceSyncPage 193 + sliceName={slice.value.name} 194 + sliceId={sliceId} 195 + currentUser={context.currentUser} 196 + collections={collections} 197 + externalCollections={externalCollections} 198 + /> 199 + ); 200 + } 201 + 202 + export const syncRoutes: Route[] = [ 203 + { 204 + method: "GET", 205 + pattern: new URLPattern({ pathname: "/slices/:id/sync" }), 206 + handler: handleSliceSyncPage, 207 + }, 208 + { 209 + method: "POST", 210 + pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }), 211 + handler: handleSliceSync, 212 + }, 213 + { 214 + method: "GET", 215 + pattern: new URLPattern({ pathname: "/api/slices/:id/job-history" }), 216 + handler: handleJobHistory, 217 + }, 218 + ];
+150
frontend/src/features/slices/sync/templates/SliceSyncPage.tsx
··· 1 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 + import { SliceTabs } from "../../shared/fragments/SliceTabs.tsx"; 3 + import { Button } from "../../../../shared/fragments/Button.tsx"; 4 + import { Textarea } from "../../../../shared/fragments/Textarea.tsx"; 5 + import { JobHistory } from "./fragments/JobHistory.tsx"; 6 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 7 + 8 + interface SliceSyncPageProps { 9 + sliceName?: string; 10 + sliceId?: string; 11 + currentUser?: AuthenticatedUser; 12 + collections?: string[]; 13 + externalCollections?: string[]; 14 + } 15 + 16 + export function SliceSyncPage({ 17 + sliceName = "My Slice", 18 + sliceId = "example", 19 + currentUser, 20 + collections = [], 21 + externalCollections = [], 22 + }: SliceSyncPageProps) { 23 + return ( 24 + <Layout title={`${sliceName} - Sync`} currentUser={currentUser}> 25 + <div> 26 + <div className="flex items-center justify-between mb-8"> 27 + <div className="flex items-center"> 28 + <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 29 + ← Back to Slices 30 + </a> 31 + <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 32 + </div> 33 + </div> 34 + 35 + <SliceTabs sliceId={sliceId} currentTab="sync" /> 36 + 37 + <div className="bg-white rounded-lg shadow-md p-6 mb-6"> 38 + <h2 className="text-xl font-semibold text-gray-800 mb-4"> 39 + Sync Collections 40 + </h2> 41 + <p className="text-gray-600 mb-6"> 42 + Sync entire collections from AT Protocol network to this slice. 43 + </p> 44 + 45 + <form 46 + hx-post={`/api/slices/${sliceId}/sync`} 47 + hx-target="#sync-result" 48 + hx-swap="innerHTML" 49 + hx-on="htmx:afterRequest: if(event.detail.successful) this.reset()" 50 + className="space-y-4" 51 + > 52 + <Textarea 53 + id="collections" 54 + name="collections" 55 + label="Primary Collections" 56 + rows={4} 57 + placeholder={ 58 + collections.length > 0 59 + ? "Primary collections (matching your slice domain) loaded below:" 60 + : "Enter primary collections matching your slice domain, one per line:\n\nyour.domain.collection\nyour.domain.post" 61 + } 62 + defaultValue={collections.length > 0 ? collections.join("\n") : ""} 63 + /> 64 + <p className="mt-1 text-xs text-gray-500"> 65 + Primary collections are those that match your slice's domain. 66 + </p> 67 + 68 + <Textarea 69 + id="external_collections" 70 + name="external_collections" 71 + label="External Collections" 72 + rows={4} 73 + placeholder={ 74 + externalCollections.length > 0 75 + ? "External collections loaded below:" 76 + : "Enter external collections (not matching your domain), one per line:\n\napp.bsky.feed.post\napp.bsky.actor.profile" 77 + } 78 + defaultValue={externalCollections.length > 0 ? externalCollections.join("\n") : ""} 79 + /> 80 + <p className="mt-1 text-xs text-gray-500"> 81 + External collections are those that don't match your slice's domain. 82 + </p> 83 + 84 + <Textarea 85 + id="repos" 86 + name="repos" 87 + label="Specific Repositories (Optional)" 88 + rows={4} 89 + placeholder="Leave empty to sync all repositories, or specify DIDs: 90 + 91 + did:plc:example1 92 + did:plc:example2" 93 + /> 94 + 95 + <div className="flex space-x-4"> 96 + <Button 97 + type="submit" 98 + variant="success" 99 + className="flex items-center justify-center" 100 + > 101 + <i 102 + data-lucide="loader-2" 103 + className="htmx-indicator animate-spin mr-2 h-4 w-4" 104 + _="on load js lucide.createIcons() end" 105 + ></i> 106 + <span className="htmx-indicator">Syncing...</span> 107 + <span className="default-text">Start Sync</span> 108 + </Button> 109 + </div> 110 + </form> 111 + 112 + <div id="sync-result" className="mt-4"></div> 113 + </div> 114 + 115 + <div 116 + hx-get={`/api/slices/${sliceId}/job-history`} 117 + hx-trigger="load, every 10s" 118 + hx-swap="innerHTML" 119 + className="mb-6" 120 + > 121 + <JobHistory jobs={[]} sliceId={sliceId} /> 122 + </div> 123 + 124 + <div className="bg-blue-50 border border-blue-200 rounded-lg p-6"> 125 + <h3 className="text-lg font-semibold text-blue-800 mb-2"> 126 + 💡 Tips for Syncing 127 + </h3> 128 + <ul className="text-blue-700 space-y-1 text-sm"> 129 + <li> 130 + • Primary collections matching your slice domain are automatically 131 + loaded in the first field 132 + </li> 133 + <li> 134 + • External collections from other domains are loaded in the second 135 + field 136 + </li> 137 + <li> 138 + • Use External Collections to sync popular collections like{" "} 139 + <code>app.bsky.feed.post</code> that aren't in your lexicons 140 + </li> 141 + <li>• External collections bypass lexicon validation</li> 142 + <li>• Large syncs may take several minutes to complete</li> 143 + <li>• Leave repositories empty to sync from all available users</li> 144 + <li>• Use the Records tab to browse synced data</li> 145 + </ul> 146 + </div> 147 + </div> 148 + </Layout> 149 + ); 150 + }
frontend/src/lib/request-logger.ts frontend/src/lib/request_logger.ts
+2 -2
frontend/src/main.ts
··· 1 1 import { route } from "@std/http/unstable-route"; 2 - import { allRoutes } from "./routes/index.ts"; 3 - import { createLoggingHandler } from "./lib/request-logger.ts"; 2 + import { allRoutes } from "./routes/mod.ts"; 3 + import { createLoggingHandler } from "./lib/request_logger.ts"; 4 4 5 5 function defaultHandler(req: Request) { 6 6 return Response.redirect(new URL("/", req.url), 302);
-112
frontend/src/pages/IndexPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - 3 - interface Slice { 4 - id: string; 5 - name: string; 6 - createdAt: string; 7 - } 8 - 9 - interface IndexPageProps { 10 - slices?: Slice[]; 11 - currentUser?: { handle?: string; isAuthenticated: boolean }; 12 - } 13 - 14 - export function IndexPage({ slices = [], currentUser }: IndexPageProps) { 15 - return ( 16 - <Layout title="Slices" currentUser={currentUser}> 17 - <div> 18 - <div className="flex justify-between items-center mb-8"> 19 - <h1 className="text-3xl font-bold text-gray-800">Slices</h1> 20 - <button 21 - type="button" 22 - className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded" 23 - hx-get="/dialogs/create-slice" 24 - hx-target="body" 25 - hx-swap="beforeend" 26 - > 27 - + Create Slice 28 - </button> 29 - </div> 30 - 31 - {slices.length > 0 ? ( 32 - <div className="bg-white rounded-lg shadow-md"> 33 - <div className="px-6 py-4 border-b border-gray-200"> 34 - <h2 className="text-lg font-semibold text-gray-800"> 35 - Your Slices ({slices.length}) 36 - </h2> 37 - </div> 38 - <div className="divide-y divide-gray-200"> 39 - {slices.map((slice) => ( 40 - <a 41 - key={slice.id} 42 - href={`/slices/${slice.id}`} 43 - className="block px-6 py-4 hover:bg-gray-50" 44 - > 45 - <div className="flex justify-between items-center"> 46 - <div> 47 - <h3 className="text-lg font-medium text-gray-900"> 48 - {slice.name} 49 - </h3> 50 - <p className="text-sm text-gray-500"> 51 - Created {new Date(slice.createdAt).toLocaleDateString()} 52 - </p> 53 - </div> 54 - <div className="text-gray-400"> 55 - <svg 56 - className="h-5 w-5" 57 - fill="none" 58 - viewBox="0 0 24 24" 59 - stroke="currentColor" 60 - > 61 - <path 62 - strokeLinecap="round" 63 - strokeLinejoin="round" 64 - strokeWidth={2} 65 - d="M9 5l7 7-7 7" 66 - /> 67 - </svg> 68 - </div> 69 - </div> 70 - </a> 71 - ))} 72 - </div> 73 - </div> 74 - ) : ( 75 - <div className="bg-white rounded-lg shadow-md p-8 text-center"> 76 - <div className="text-gray-400 mb-4"> 77 - <svg 78 - className="mx-auto h-16 w-16" 79 - fill="none" 80 - viewBox="0 0 24 24" 81 - stroke="currentColor" 82 - > 83 - <path 84 - strokeLinecap="round" 85 - strokeLinejoin="round" 86 - strokeWidth={1} 87 - d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 88 - /> 89 - </svg> 90 - </div> 91 - <h3 className="text-lg font-medium text-gray-900 mb-2"> 92 - No slices yet 93 - </h3> 94 - <p className="text-gray-500 mb-6"> 95 - Create your first slice to get started organizing your AT Protocol 96 - data. 97 - </p> 98 - <button 99 - type="button" 100 - className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded" 101 - hx-get="/dialogs/create-slice" 102 - hx-target="body" 103 - hx-swap="beforeend" 104 - > 105 - Create Your First Slice 106 - </button> 107 - </div> 108 - )} 109 - </div> 110 - </Layout> 111 - ); 112 - }
+7 -6
frontend/src/pages/JetstreamLogsPage.tsx frontend/src/features/slices/jetstream/templates/JetstreamLogsPage.tsx
··· 1 - import type { LogEntry } from "../client.ts"; 2 - import { Layout } from "../components/Layout.tsx"; 3 - import { JetstreamLogs } from "../components/JetstreamLogs.tsx"; 4 - import { JetstreamStatusCompact } from "../components/JetstreamStatusCompact.tsx"; 1 + import type { LogEntry } from "../../../../client.ts"; 2 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 3 + import { JetstreamLogs } from "./fragments/JetstreamLogs.tsx"; 4 + import { JetstreamStatusCompact } from "./fragments/JetstreamStatusCompact.tsx"; 5 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 5 6 6 7 interface JetstreamLogsPageProps { 7 8 logs: LogEntry[]; 8 9 sliceId: string; 9 - currentUser?: { handle?: string; isAuthenticated: boolean }; 10 + currentUser?: AuthenticatedUser; 10 11 } 11 12 12 13 export function JetstreamLogsPage({ ··· 41 42 </div> 42 43 </Layout> 43 44 ); 44 - } 45 + }
-84
frontend/src/pages/LoginPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - 3 - interface LoginPageProps { 4 - error?: string; 5 - currentUser?: { handle?: string; isAuthenticated: boolean }; 6 - } 7 - 8 - export function LoginPage({ error, currentUser }: LoginPageProps) { 9 - return ( 10 - <Layout title="Login - Slice" currentUser={currentUser}> 11 - <div className="max-w-md mx-auto mt-16"> 12 - <div className="bg-white rounded-lg shadow-md p-8"> 13 - <div className="text-center mb-8"> 14 - <h1 className="text-3xl font-bold text-gray-800 mb-2"> 15 - Welcome to Slices 16 - </h1> 17 - <p className="text-gray-600"> 18 - Sign in with your AT Protocol handle 19 - </p> 20 - </div> 21 - 22 - {error && ( 23 - <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6"> 24 - <p className="text-red-700 text-sm">{error}</p> 25 - </div> 26 - )} 27 - 28 - <form method="post" action="/oauth/authorize" className="space-y-6"> 29 - <div> 30 - <label 31 - htmlFor="loginHint" 32 - className="block text-sm font-medium text-gray-700 mb-2" 33 - > 34 - AT Protocol Handle 35 - </label> 36 - <input 37 - type="text" 38 - id="loginHint" 39 - name="loginHint" 40 - placeholder="alice.bsky.social" 41 - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" 42 - required 43 - /> 44 - <p className="text-xs text-gray-500 mt-1"> 45 - Enter your Bluesky handle or custom domain 46 - </p> 47 - </div> 48 - 49 - <button 50 - type="submit" 51 - className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-md transition-colors" 52 - > 53 - Sign In with OAuth 54 - </button> 55 - </form> 56 - 57 - <div className="mt-8 text-center"> 58 - <p className="text-sm text-gray-500 mb-4"> 59 - Don't have an AT Protocol account? 60 - </p> 61 - <div className="space-y-2"> 62 - <a 63 - href="https://bsky.app" 64 - target="_blank" 65 - rel="noopener noreferrer" 66 - className="block text-blue-600 hover:text-blue-800 text-sm" 67 - > 68 - Create account on Bluesky → 69 - </a> 70 - <a 71 - href="https://atproto.com" 72 - target="_blank" 73 - rel="noopener noreferrer" 74 - className="block text-blue-600 hover:text-blue-800 text-sm" 75 - > 76 - Learn about AT Protocol → 77 - </a> 78 - </div> 79 - </div> 80 - </div> 81 - </div> 82 - </Layout> 83 - ); 84 - }
-33
frontend/src/pages/SettingsPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { SettingsForm } from "../components/SettingsForm.tsx"; 3 - 4 - interface SettingsPageProps { 5 - profile?: { 6 - displayName?: string; 7 - description?: string; 8 - avatar?: string; 9 - }; 10 - error?: string; 11 - currentUser?: { handle?: string; isAuthenticated: boolean }; 12 - } 13 - 14 - export function SettingsPage({ 15 - profile, 16 - error, 17 - currentUser, 18 - }: SettingsPageProps) { 19 - return ( 20 - <Layout title="Settings - Slice" currentUser={currentUser}> 21 - <div> 22 - <div className="mb-8"> 23 - <h1 className="text-3xl font-bold text-gray-900">Settings</h1> 24 - <p className="mt-2 text-gray-600"> 25 - Manage your profile information and preferences. 26 - </p> 27 - </div> 28 - 29 - <SettingsForm profile={profile} error={error} /> 30 - </div> 31 - </Layout> 32 - ); 33 - }
+4 -6
frontend/src/pages/SliceApiDocsPage.tsx frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx
··· 1 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 2 + 1 3 interface SliceApiDocsPageProps { 2 4 sliceId: string; 3 5 sliceName: string; 4 6 accessToken?: string; 5 - currentUser: { 6 - isAuthenticated: boolean; 7 - username?: string; 8 - sub?: string; 9 - }; 7 + currentUser: AuthenticatedUser; 10 8 } 11 9 12 10 export function SliceApiDocsPage(props: SliceApiDocsPageProps) { ··· 188 186 </body> 189 187 </html> 190 188 ); 191 - } 189 + }
+5 -7
frontend/src/pages/SliceCodegenPage.tsx frontend/src/features/slices/codegen/templates/SliceCodegenPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { CodegenForm } from "../components/CodegenForm.tsx"; 3 - import { SliceTabs } from "../components/SliceTabs.tsx"; 1 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 + import { SliceTabs } from "../../shared/fragments/SliceTabs.tsx"; 3 + import { CodegenForm } from "./fragments/CodegenForm.tsx"; 4 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 4 5 5 6 interface SliceCodegenPageProps { 6 7 sliceName?: string; 7 8 sliceId?: string; 8 - currentUser?: { handle?: string; isAuthenticated: boolean }; 9 + currentUser?: AuthenticatedUser; 9 10 } 10 11 11 12 export function SliceCodegenPage({ ··· 13 14 sliceId = "example", 14 15 currentUser, 15 16 }: SliceCodegenPageProps) { 16 - 17 17 return ( 18 18 <Layout title={`${sliceName} - Code Generation`} currentUser={currentUser}> 19 19 <div> ··· 31 31 </div> 32 32 </div> 33 33 34 - {/* Tab Navigation */} 35 34 <SliceTabs sliceId={sliceId} currentTab="codegen" /> 36 35 37 36 <CodegenForm sliceId={sliceId} /> 38 - 39 37 40 38 <div className="mt-6 bg-green-50 border border-green-200 rounded-lg p-6"> 41 39 <h3 className="text-lg font-semibold text-green-800 mb-2">
+12 -57
frontend/src/pages/SliceLexiconPage.tsx frontend/src/features/slices/lexicon/templates/SliceLexiconPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { EmptyLexiconState } from "../components/EmptyLexiconState.tsx"; 3 - import { SliceTabs } from "../components/SliceTabs.tsx"; 1 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 + import { SliceTabs } from "../../shared/fragments/SliceTabs.tsx"; 3 + import { EmptyLexiconState } from "./fragments/EmptyLexiconState.tsx"; 4 + import { Button } from "../../../../shared/fragments/Button.tsx"; 5 + import { Textarea } from "../../../../shared/fragments/Textarea.tsx"; 6 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 4 7 5 8 interface SliceLexiconPageProps { 6 9 sliceName?: string; 7 10 sliceId?: string; 8 - currentUser?: { handle?: string; isAuthenticated: boolean }; 11 + currentUser?: AuthenticatedUser; 9 12 } 10 13 11 14 export function SliceLexiconPage({ ··· 13 16 sliceId = "example", 14 17 currentUser, 15 18 }: SliceLexiconPageProps) { 16 - 17 19 return ( 18 20 <Layout title={`${sliceName} - Lexicons`} currentUser={currentUser}> 19 21 <div> ··· 26 28 </div> 27 29 </div> 28 30 29 - {/* Tab Navigation */} 30 31 <SliceTabs sliceId={sliceId} currentTab="lexicon" /> 31 32 32 33 <div className="bg-white rounded-lg shadow-md p-6 mb-6"> ··· 45 46 className="space-y-4" 46 47 > 47 48 <div> 48 - <label className="block text-sm font-medium text-gray-700 mb-2"> 49 - Lexicon JSON 50 - </label> 51 - <textarea 49 + <Textarea 50 + label="Lexicon JSON" 52 51 name="lexicon_json" 53 52 rows={12} 54 - className="block w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm" 53 + class="font-mono text-sm" 55 54 placeholder={`{ 56 55 "lexicon": 1, 57 56 "id": "social.slices.example", ··· 84 83 </p> 85 84 </div> 86 85 87 - <button 88 - type="submit" 89 - className="bg-purple-500 hover:bg-purple-600 text-white px-6 py-2 rounded-md" 90 - > 86 + <Button type="submit" variant="purple"> 91 87 Add Lexicon 92 - </button> 88 + </Button> 93 89 </form> 94 90 95 91 <div id="lexicon-result" className="mt-4"></div> 96 92 </div> 97 93 98 - <div className="bg-white rounded-lg shadow-md p-6 mb-6 hidden"> 99 - <h2 className="text-xl font-semibold text-gray-800 mb-4"> 100 - Upload Lexicon Files 101 - </h2> 102 - <p className="text-gray-600 mb-6"> 103 - Or upload lexicon schema files to define custom record types for 104 - this slice. 105 - </p> 106 - 107 - <form 108 - method="post" 109 - action={`/slices/${sliceId}/lexicon/upload`} 110 - enctype="multipart/form-data" 111 - className="space-y-4" 112 - > 113 - <div> 114 - <label className="block text-sm font-medium text-gray-700 mb-2"> 115 - Lexicon File 116 - </label> 117 - <input 118 - type="file" 119 - name="lexicon" 120 - accept=".zip,.json" 121 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 122 - /> 123 - <p className="text-sm text-gray-500 mt-1"> 124 - Upload a ZIP file containing lexicon definitions or a single 125 - JSON file 126 - </p> 127 - </div> 128 - 129 - <button 130 - type="submit" 131 - className="bg-purple-500 hover:bg-purple-600 text-white px-6 py-2 rounded-md" 132 - > 133 - Upload Lexicon 134 - </button> 135 - </form> 136 - </div> 137 - 138 94 <div className="bg-white rounded-lg shadow-md"> 139 95 <div className="px-6 py-4 border-b border-gray-200"> 140 96 <h2 className="text-lg font-semibold text-gray-800"> ··· 166 122 </ul> 167 123 </div> 168 124 169 - {/* Modal container for viewing lexicons */} 170 125 <div id="lexicon-modal"></div> 171 126 </div> 172 127 </Layout>
-174
frontend/src/pages/SliceOAuthPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { SliceTabs } from "../components/SliceTabs.tsx"; 3 - 4 - interface OAuthClient { 5 - clientId: string; 6 - createdAt: string; 7 - clientName?: string; 8 - redirectUris?: string[]; 9 - } 10 - 11 - interface SliceOAuthPageProps { 12 - sliceName?: string; 13 - sliceId?: string; 14 - clients?: OAuthClient[]; 15 - currentUser?: { handle?: string; isAuthenticated: boolean }; 16 - error?: string | null; 17 - success?: string | null; 18 - } 19 - 20 - export function SliceOAuthPage({ 21 - sliceName = "My Slice", 22 - sliceId = "example", 23 - clients = [], 24 - currentUser, 25 - error = null, 26 - success = null, 27 - }: SliceOAuthPageProps) { 28 - return ( 29 - <Layout title={`${sliceName} - OAuth Clients`} currentUser={currentUser}> 30 - <div> 31 - <div className="flex items-center justify-between mb-8"> 32 - <div className="flex items-center"> 33 - <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 34 - ← Back to Slices 35 - </a> 36 - <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 37 - </div> 38 - </div> 39 - 40 - {/* Tab Navigation */} 41 - <SliceTabs sliceId={sliceId} currentTab="oauth" /> 42 - 43 - {/* Success Message */} 44 - {success && ( 45 - <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"> 46 - ✅ {success} 47 - </div> 48 - )} 49 - 50 - {/* Error Message */} 51 - {error && ( 52 - <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> 53 - ❌ {error} 54 - </div> 55 - )} 56 - 57 - {/* OAuth Clients Content */} 58 - <div className="bg-white rounded-lg shadow-md p-6"> 59 - <div className="flex justify-between items-center mb-6"> 60 - <h2 className="text-2xl font-semibold text-gray-800"> 61 - OAuth Clients 62 - </h2> 63 - <button 64 - type="button" 65 - hx-get={`/api/slices/${sliceId}/oauth/new`} 66 - hx-target="#modal-container" 67 - hx-swap="innerHTML" 68 - className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition" 69 - > 70 - Register New Client 71 - </button> 72 - </div> 73 - 74 - {clients.length === 0 ? ( 75 - <div className="text-center py-12"> 76 - <p className="text-gray-600 mb-4"> 77 - No OAuth clients registered for this slice. 78 - </p> 79 - <button 80 - type="button" 81 - hx-get={`/api/slices/${sliceId}/oauth/new`} 82 - hx-target="#modal-container" 83 - hx-swap="innerHTML" 84 - className="text-blue-600 hover:text-blue-800" 85 - > 86 - Register your first OAuth client 87 - </button> 88 - </div> 89 - ) : ( 90 - <div className="overflow-x-auto"> 91 - <table className="w-full"> 92 - <thead> 93 - <tr className="border-b"> 94 - <th className="text-left py-2 px-4">Client ID</th> 95 - <th className="text-left py-2 px-4">Name</th> 96 - <th className="text-left py-2 px-4">Redirect URIs</th> 97 - <th className="text-left py-2 px-4">Created</th> 98 - <th className="text-left py-2 px-4">Actions</th> 99 - </tr> 100 - </thead> 101 - <tbody> 102 - {clients.map((client) => ( 103 - <tr 104 - key={client.clientId} 105 - className="border-b hover:bg-gray-50" 106 - > 107 - <td className="py-3 px-4 font-mono text-sm"> 108 - {client.clientId} 109 - </td> 110 - <td className="py-3 px-4"> 111 - {client.clientName || "Loading..."} 112 - </td> 113 - <td className="py-3 px-4"> 114 - {client.redirectUris ? ( 115 - <div className="text-sm"> 116 - {client.redirectUris.slice(0, 2).map((uri, idx) => ( 117 - <div key={idx} className="truncate max-w-xs"> 118 - {uri} 119 - </div> 120 - ))} 121 - {client.redirectUris.length > 2 && ( 122 - <div className="text-gray-500"> 123 - +{client.redirectUris.length - 2} more 124 - </div> 125 - )} 126 - </div> 127 - ) : ( 128 - <span className="text-gray-400">Loading...</span> 129 - )} 130 - </td> 131 - <td className="py-3 px-4 text-sm text-gray-600"> 132 - {new Date(client.createdAt).toLocaleDateString()} 133 - </td> 134 - <td className="py-3 px-4"> 135 - <div className="flex gap-2"> 136 - <button 137 - type="button" 138 - hx-get={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 139 - client.clientId 140 - )}/view`} 141 - hx-target="#modal-container" 142 - hx-swap="innerHTML" 143 - className="text-blue-600 hover:text-blue-800 text-sm" 144 - > 145 - View 146 - </button> 147 - <button 148 - type="button" 149 - hx-delete={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 150 - client.clientId 151 - )}`} 152 - hx-confirm="Are you sure you want to delete this OAuth client?" 153 - hx-target="closest tr" 154 - hx-swap="outerHTML" 155 - className="text-red-600 hover:text-red-800 text-sm" 156 - > 157 - Delete 158 - </button> 159 - </div> 160 - </td> 161 - </tr> 162 - ))} 163 - </tbody> 164 - </table> 165 - </div> 166 - )} 167 - </div> 168 - 169 - {/* Modal Container */} 170 - <div id="modal-container"></div> 171 - </div> 172 - </Layout> 173 - ); 174 - }
+24 -24
frontend/src/pages/SlicePage.tsx frontend/src/features/slices/overview/templates/SliceOverview.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { SliceTabs } from "../components/SliceTabs.tsx"; 1 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 2 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 3 + import { SliceTabs } from "../../shared/fragments/SliceTabs.tsx"; 4 + import { Button } from "../../../../shared/fragments/Button.tsx"; 3 5 4 6 interface Collection { 5 7 name: string; ··· 7 9 actors?: number; 8 10 } 9 11 10 - interface SlicePageProps { 12 + interface SliceOverviewProps { 11 13 totalRecords?: number; 12 14 totalActors?: number; 13 15 totalLexicons?: number; ··· 15 17 sliceName?: string; 16 18 sliceId?: string; 17 19 currentTab?: string; 18 - currentUser?: { handle?: string; isAuthenticated: boolean }; 20 + currentUser?: AuthenticatedUser; 19 21 } 20 22 21 - export function SlicePage({ 23 + export function SliceOverview({ 22 24 totalRecords = 0, 23 25 totalActors = 0, 24 26 totalLexicons = 0, ··· 27 29 sliceId = "example", 28 30 currentTab = "overview", 29 31 currentUser, 30 - }: SlicePageProps) { 32 + }: SliceOverviewProps) { 31 33 return ( 32 34 <Layout title={sliceName} currentUser={currentUser}> 33 35 <div> ··· 40 42 </div> 41 43 </div> 42 44 43 - {/* Tab Navigation */} 44 45 <SliceTabs sliceId={sliceId} currentTab={currentTab} /> 45 46 46 - {/* Jetstream Status */} 47 47 <div 48 48 hx-get={`/api/jetstream/status?sliceId=${sliceId}`} 49 49 hx-trigger="load, every 2m" ··· 99 99 <p className="text-gray-600 mb-4"> 100 100 View lexicon definitions and schemas that define your slice. 101 101 </p> 102 - <a 102 + <Button 103 103 href={`/slices/${sliceId}/lexicon`} 104 - className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded" 104 + variant="purple" 105 105 > 106 106 View Lexicons 107 - </a> 107 + </Button> 108 108 </div> 109 109 110 110 <div className="bg-white rounded-lg shadow-md p-6"> ··· 115 115 Browse indexed AT Protocol records by collection. 116 116 </p> 117 117 {collections.length > 0 ? ( 118 - <a 118 + <Button 119 119 href={`/slices/${sliceId}/records`} 120 - className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded" 120 + variant="primary" 121 121 > 122 122 Browse Records 123 - </a> 123 + </Button> 124 124 ) : ( 125 125 <p className="text-gray-500 text-sm"> 126 126 No records synced yet. Start by syncing some records! ··· 135 135 <p className="text-gray-600 mb-4"> 136 136 Generate TypeScript client from your lexicon definitions. 137 137 </p> 138 - <a 138 + <Button 139 139 href={`/slices/${sliceId}/codegen`} 140 - className="bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded" 140 + variant="warning" 141 141 > 142 142 Generate Client 143 - </a> 143 + </Button> 144 144 </div> 145 145 146 146 <div className="bg-white rounded-lg shadow-md p-6"> ··· 150 150 <p className="text-gray-600 mb-4"> 151 151 Interactive OpenAPI documentation for your slice's XRPC endpoints. 152 152 </p> 153 - <a 153 + <Button 154 154 href={`/slices/${sliceId}/api-docs`} 155 - className="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded" 155 + variant="indigo" 156 156 > 157 157 View API Docs 158 - </a> 158 + </Button> 159 159 </div> 160 160 161 161 <div className="bg-white rounded-lg shadow-md p-6"> ··· 165 165 <p className="text-gray-600 mb-4"> 166 166 Sync entire collections from AT Protocol network. 167 167 </p> 168 - <a 168 + <Button 169 169 href={`/slices/${sliceId}/sync`} 170 - className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded" 170 + variant="success" 171 171 > 172 172 Start Sync 173 - </a> 173 + </Button> 174 174 </div> 175 175 176 176 {collections.length > 0 ? ( ··· 227 227 </div> 228 228 </Layout> 229 229 ); 230 - } 230 + }
-232
frontend/src/pages/SliceRecordsPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { SliceTabs } from "../components/SliceTabs.tsx"; 3 - 4 - interface Record { 5 - uri: string; 6 - indexedAt: string; 7 - collection: string; 8 - did: string; 9 - cid: string; 10 - value?: any; 11 - pretty_value?: string; 12 - } 13 - 14 - interface AvailableCollection { 15 - name: string; 16 - count: number; 17 - } 18 - 19 - interface SliceRecordsPageProps { 20 - records?: Record[]; 21 - availableCollections?: AvailableCollection[]; 22 - collection?: string; 23 - author?: string; 24 - search?: string; 25 - sliceName?: string; 26 - sliceId?: string; 27 - currentUser?: { handle?: string; isAuthenticated: boolean }; 28 - } 29 - 30 - export function SliceRecordsPage({ 31 - records = [], 32 - availableCollections = [], 33 - collection = "", 34 - author = "", 35 - search = "", 36 - sliceName = "My Slice", 37 - sliceId = "example", 38 - currentUser, 39 - }: SliceRecordsPageProps) { 40 - return ( 41 - <Layout title={`${sliceName} - Records`} currentUser={currentUser}> 42 - <div> 43 - <div className="flex items-center justify-between mb-8"> 44 - <div className="flex items-center"> 45 - <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 46 - ← Back to Slices 47 - </a> 48 - <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 49 - </div> 50 - </div> 51 - 52 - {/* Tab Navigation */} 53 - <SliceTabs sliceId={sliceId} currentTab="records" /> 54 - 55 - <div className="bg-white rounded-lg shadow-md p-6 mb-6"> 56 - <div className="flex justify-between items-center mb-4"> 57 - <h2 className="text-xl font-semibold">Filter Records</h2> 58 - </div> 59 - <form 60 - className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" 61 - method="get" 62 - _="on submit 63 - if #author.value is empty 64 - remove @name from #author 65 - end 66 - if #search.value is empty 67 - remove @name from #search 68 - end" 69 - > 70 - <div> 71 - <label className="block text-sm font-medium text-gray-700 mb-2"> 72 - Collection 73 - </label> 74 - <select 75 - name="collection" 76 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 77 - > 78 - <option value="">All Collections</option> 79 - {availableCollections.map((coll) => ( 80 - <option 81 - key={coll.name} 82 - value={coll.name} 83 - selected={coll.name === collection} 84 - > 85 - {coll.name} ({coll.count}) 86 - </option> 87 - ))} 88 - </select> 89 - </div> 90 - 91 - <div> 92 - <label className="block text-sm font-medium text-gray-700 mb-2"> 93 - Author DID 94 - </label> 95 - <input 96 - type="text" 97 - name="author" 98 - id="author" 99 - value={author} 100 - placeholder="did:plc:..." 101 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 102 - /> 103 - </div> 104 - 105 - <div> 106 - <label className="block text-sm font-medium text-gray-700 mb-2"> 107 - Search 108 - </label> 109 - <input 110 - type="text" 111 - name="search" 112 - id="search" 113 - value={search} 114 - placeholder="Search in record content..." 115 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 116 - /> 117 - </div> 118 - 119 - <div className="flex items-end"> 120 - <button 121 - type="submit" 122 - className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md" 123 - > 124 - {search ? "Search" : "Filter"} 125 - </button> 126 - </div> 127 - </form> 128 - </div> 129 - 130 - {records.length > 0 ? ( 131 - <div className="bg-white rounded-lg shadow-md"> 132 - <div className="px-6 py-4 border-b border-gray-200"> 133 - <h2 className="text-lg font-semibold"> 134 - Records ({records.length}) 135 - </h2> 136 - </div> 137 - <div className="divide-y divide-gray-200"> 138 - {records.map((record) => ( 139 - <div key={record.uri} className="p-6"> 140 - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> 141 - <div> 142 - <h3 className="text-lg font-medium text-gray-900 mb-2"> 143 - Metadata 144 - </h3> 145 - <dl className="grid grid-cols-1 gap-x-4 gap-y-2 text-sm"> 146 - <div className="grid grid-cols-3 gap-4"> 147 - <dt className="font-medium text-gray-500">URI:</dt> 148 - <dd className="col-span-2 text-gray-900 break-all"> 149 - {record.uri} 150 - </dd> 151 - </div> 152 - <div className="grid grid-cols-3 gap-4"> 153 - <dt className="font-medium text-gray-500"> 154 - Collection: 155 - </dt> 156 - <dd className="col-span-2 text-gray-900"> 157 - {record.collection} 158 - </dd> 159 - </div> 160 - <div className="grid grid-cols-3 gap-4"> 161 - <dt className="font-medium text-gray-500">DID:</dt> 162 - <dd className="col-span-2 text-gray-900 break-all"> 163 - {record.did} 164 - </dd> 165 - </div> 166 - <div className="grid grid-cols-3 gap-4"> 167 - <dt className="font-medium text-gray-500">CID:</dt> 168 - <dd className="col-span-2 text-gray-900 break-all"> 169 - {record.cid} 170 - </dd> 171 - </div> 172 - <div className="grid grid-cols-3 gap-4"> 173 - <dt className="font-medium text-gray-500"> 174 - Indexed: 175 - </dt> 176 - <dd className="col-span-2 text-gray-900"> 177 - {new Date(record.indexedAt).toLocaleString()} 178 - </dd> 179 - </div> 180 - </dl> 181 - </div> 182 - <div> 183 - <h3 className="text-lg font-medium text-gray-900 mb-2"> 184 - Record Data 185 - </h3> 186 - <pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64"> 187 - {record.pretty_value || 188 - JSON.stringify(record.value, null, 2)} 189 - </pre> 190 - </div> 191 - </div> 192 - </div> 193 - ))} 194 - </div> 195 - </div> 196 - ) : ( 197 - <div className="bg-white rounded-lg shadow-md p-8 text-center"> 198 - <div className="text-gray-400 mb-4"> 199 - <svg 200 - className="mx-auto h-16 w-16" 201 - fill="none" 202 - viewBox="0 0 24 24" 203 - stroke="currentColor" 204 - > 205 - <path 206 - strokeLinecap="round" 207 - strokeLinejoin="round" 208 - strokeWidth={1} 209 - d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 210 - /> 211 - </svg> 212 - </div> 213 - <h3 className="text-lg font-medium text-gray-900 mb-2"> 214 - No records found 215 - </h3> 216 - <p className="text-gray-500 mb-6"> 217 - {collection || author || search 218 - ? "Try adjusting your filters or search terms, or sync some data first." 219 - : "Start by syncing some AT Protocol collections."} 220 - </p> 221 - <a 222 - href={`/slices/${sliceId}/sync`} 223 - className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded" 224 - > 225 - Go to Sync 226 - </a> 227 - </div> 228 - )} 229 - </div> 230 - </Layout> 231 - ); 232 - }
+29 -38
frontend/src/pages/SliceSettingsPage.tsx frontend/src/features/slices/settings/templates/SliceSettings.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { SliceTabs } from "../components/SliceTabs.tsx"; 1 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 2 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 3 + import { SliceTabs } from "../../shared/fragments/SliceTabs.tsx"; 4 + import { Button } from "../../../../shared/fragments/Button.tsx"; 5 + import { Input } from "../../../../shared/fragments/Input.tsx"; 3 6 4 - interface SliceSettingsPageProps { 7 + interface SliceSettingsProps { 5 8 sliceName?: string; 6 9 sliceDomain?: string; 7 10 sliceId?: string; 8 11 updated?: boolean; 9 12 error?: string | null; 10 - currentUser?: { handle?: string; isAuthenticated: boolean }; 13 + currentUser?: AuthenticatedUser; 11 14 } 12 15 13 - export function SliceSettingsPage({ 16 + export function SliceSettings({ 14 17 sliceName = "My Slice", 15 18 sliceDomain = "", 16 19 sliceId = "example", 17 20 updated = false, 18 21 error = null, 19 22 currentUser, 20 - }: SliceSettingsPageProps) { 23 + }: SliceSettingsProps) { 21 24 return ( 22 25 <Layout title={`${sliceName} - Settings`} currentUser={currentUser}> 23 26 <div> ··· 63 66 hx-swap="innerHTML" 64 67 className="space-y-4" 65 68 > 66 - <div> 67 - <label 68 - htmlFor="slice-name" 69 - className="block text-sm font-medium text-gray-700 mb-2" 70 - > 71 - Slice Name 72 - </label> 73 - <input 74 - type="text" 75 - id="slice-name" 76 - name="name" 77 - value={sliceName} 78 - required 79 - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" 80 - placeholder="Enter slice name..." 81 - /> 82 - </div> 69 + <Input 70 + label="Slice Name" 71 + type="text" 72 + id="slice-name" 73 + name="name" 74 + value={sliceName} 75 + required 76 + placeholder="Enter slice name..." 77 + /> 83 78 84 79 <div> 85 - <label 86 - htmlFor="slice-domain" 87 - className="block text-sm font-medium text-gray-700 mb-2" 88 - > 89 - Primary Domain 90 - </label> 91 - <input 80 + <Input 81 + label="Primary Domain" 92 82 type="text" 93 83 id="slice-domain" 94 84 name="domain" 95 85 value={sliceDomain} 96 86 required 97 - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" 98 87 placeholder="e.g. social.grain" 99 88 /> 100 89 <p className="mt-1 text-xs text-gray-500"> ··· 103 92 </div> 104 93 105 94 <div className="flex justify-start"> 106 - <button 95 + <Button 107 96 type="submit" 108 - className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-md font-medium" 97 + variant="primary" 98 + size="lg" 109 99 > 110 100 Update Settings 111 - </button> 101 + </Button> 112 102 </div> 113 103 <div id="settings-form-result" className="mt-4"></div> 114 104 </form> ··· 123 113 Permanently delete this slice and all associated data. This action 124 114 cannot be undone. 125 115 </p> 126 - <button 116 + <Button 127 117 type="button" 128 118 hx-delete={`/api/slices/${sliceId}`} 129 119 hx-confirm="Are you sure you want to delete this slice? This action cannot be undone." 130 120 hx-target="body" 131 121 hx-push-url="/" 132 - className="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-md font-medium" 122 + variant="danger" 123 + size="lg" 133 124 > 134 125 Delete Slice 135 - </button> 126 + </Button> 136 127 </div> 137 128 </div> 138 129 </div> 139 130 </Layout> 140 131 ); 141 - } 132 + }
-170
frontend/src/pages/SliceSyncPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 2 - import { SliceTabs } from "../components/SliceTabs.tsx"; 3 - import { JobHistory } from "../components/JobHistory.tsx"; 4 - 5 - interface SliceSyncPageProps { 6 - sliceName?: string; 7 - sliceId?: string; 8 - currentUser?: { handle?: string; isAuthenticated: boolean }; 9 - collections?: string[]; 10 - externalCollections?: string[]; 11 - } 12 - 13 - export function SliceSyncPage({ 14 - sliceName = "My Slice", 15 - sliceId = "example", 16 - currentUser, 17 - collections = [], 18 - externalCollections = [], 19 - }: SliceSyncPageProps) { 20 - return ( 21 - <Layout title={`${sliceName} - Sync`} currentUser={currentUser}> 22 - <div> 23 - <div className="flex items-center justify-between mb-8"> 24 - <div className="flex items-center"> 25 - <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 26 - ← Back to Slices 27 - </a> 28 - <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 29 - </div> 30 - </div> 31 - 32 - {/* Tab Navigation */} 33 - <SliceTabs sliceId={sliceId} currentTab="sync" /> 34 - 35 - <div className="bg-white rounded-lg shadow-md p-6 mb-6"> 36 - <h2 className="text-xl font-semibold text-gray-800 mb-4"> 37 - Sync Collections 38 - </h2> 39 - <p className="text-gray-600 mb-6"> 40 - Sync entire collections from AT Protocol network to this slice. 41 - </p> 42 - 43 - <form 44 - hx-post={`/api/slices/${sliceId}/sync`} 45 - hx-target="#sync-result" 46 - hx-swap="innerHTML" 47 - hx-on="htmx:afterRequest: if(event.detail.successful) this.reset()" 48 - className="space-y-4" 49 - > 50 - <div> 51 - <label className="block text-sm font-medium text-gray-700 mb-2"> 52 - Primary Collections 53 - </label> 54 - <textarea 55 - id="collections" 56 - name="collections" 57 - rows={4} 58 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 59 - placeholder={ 60 - collections.length > 0 61 - ? "Primary collections (matching your slice domain) loaded below:" 62 - : "Enter primary collections matching your slice domain, one per line:\n\nyour.domain.collection\nyour.domain.post" 63 - } 64 - > 65 - {collections.length > 0 ? collections.join("\n") : ""} 66 - </textarea> 67 - <p className="mt-1 text-xs text-gray-500"> 68 - Primary collections are those that match your slice's domain. 69 - </p> 70 - </div> 71 - 72 - <div> 73 - <label className="block text-sm font-medium text-gray-700 mb-2"> 74 - External Collections 75 - </label> 76 - <textarea 77 - id="external_collections" 78 - name="external_collections" 79 - rows={4} 80 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 81 - placeholder={ 82 - externalCollections.length > 0 83 - ? "External collections loaded below:" 84 - : "Enter external collections (not matching your domain), one per line:\n\napp.bsky.feed.post\napp.bsky.actor.profile" 85 - } 86 - > 87 - {externalCollections.length > 0 88 - ? externalCollections.join("\n") 89 - : ""} 90 - </textarea> 91 - <p className="mt-1 text-xs text-gray-500"> 92 - External collections are those that don't match your slice's 93 - domain. 94 - </p> 95 - </div> 96 - 97 - <div> 98 - <label className="block text-sm font-medium text-gray-700 mb-2"> 99 - Specific Repositories (Optional) 100 - </label> 101 - <textarea 102 - id="repos" 103 - name="repos" 104 - rows={4} 105 - className="block w-full border border-gray-300 rounded-md px-3 py-2" 106 - placeholder="Leave empty to sync all repositories, or specify DIDs: 107 - 108 - did:plc:example1 109 - did:plc:example2" 110 - /> 111 - </div> 112 - 113 - <div className="flex space-x-4"> 114 - <button 115 - type="submit" 116 - className="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-md flex items-center justify-center" 117 - > 118 - <i 119 - data-lucide="loader-2" 120 - className="htmx-indicator animate-spin mr-2 h-4 w-4" 121 - _="on load js lucide.createIcons() end" 122 - ></i> 123 - <span className="htmx-indicator">Syncing...</span> 124 - <span className="default-text">Start Sync</span> 125 - </button> 126 - </div> 127 - </form> 128 - 129 - <div id="sync-result" className="mt-4"> 130 - {/* Results will be loaded here via htmx */} 131 - </div> 132 - </div> 133 - 134 - {/* Job History */} 135 - <div 136 - hx-get={`/api/slices/${sliceId}/job-history`} 137 - hx-trigger="load, every 10s" 138 - hx-swap="innerHTML" 139 - className="mb-6" 140 - > 141 - <JobHistory jobs={[]} sliceId={sliceId} /> 142 - </div> 143 - 144 - <div className="bg-blue-50 border border-blue-200 rounded-lg p-6"> 145 - <h3 className="text-lg font-semibold text-blue-800 mb-2"> 146 - 💡 Tips for Syncing 147 - </h3> 148 - <ul className="text-blue-700 space-y-1 text-sm"> 149 - <li> 150 - • Primary collections matching your slice domain are automatically 151 - loaded in the first field 152 - </li> 153 - <li> 154 - • External collections from other domains are loaded in the second 155 - field 156 - </li> 157 - <li> 158 - • Use External Collections to sync popular collections like{" "} 159 - <code>app.bsky.feed.post</code> that aren't in your lexicons 160 - </li> 161 - <li>• External collections bypass lexicon validation</li> 162 - <li>• Large syncs may take several minutes to complete</li> 163 - <li>• Leave repositories empty to sync from all available users</li> 164 - <li>• Use the Records tab to browse synced data</li> 165 - </ul> 166 - </div> 167 - </div> 168 - </Layout> 169 - ); 170 - }
+3 -2
frontend/src/pages/SyncJobLogsPage.tsx frontend/src/features/slices/sync-logs/templates/SyncJobLogsPage.tsx
··· 1 - import { Layout } from "../components/Layout.tsx"; 1 + import { Layout } from "../../../../shared/fragments/Layout.tsx"; 2 + import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 2 3 3 4 interface SyncJobLogsPageProps { 4 5 sliceName?: string; 5 6 sliceId?: string; 6 7 jobId?: string; 7 - currentUser?: { handle?: string; isAuthenticated: boolean }; 8 + currentUser?: AuthenticatedUser; 8 9 } 9 10 10 11 export function SyncJobLogsPage({
-24
frontend/src/routes/dialogs.tsx
··· 1 - import type { Route } from "@std/http/unstable-route"; 2 - import { render } from "preact-render-to-string"; 3 - import { withAuth, requireAuth } from "./middleware.ts"; 4 - import { CreateSliceDialog } from "../components/CreateSliceDialog.tsx"; 5 - 6 - async function handleCreateSliceDialog(req: Request): Promise<Response> { 7 - const context = await withAuth(req); 8 - const authResponse = requireAuth(context); 9 - if (authResponse) return authResponse; 10 - 11 - const dialogHtml = render(<CreateSliceDialog />); 12 - return new Response(dialogHtml, { 13 - status: 200, 14 - headers: { "content-type": "text/html" }, 15 - }); 16 - } 17 - 18 - export const dialogRoutes: Route[] = [ 19 - { 20 - method: "GET", 21 - pattern: new URLPattern({ pathname: "/dialogs/create-slice" }), 22 - handler: handleCreateSliceDialog, 23 - }, 24 - ];
-12
frontend/src/routes/index.ts
··· 1 - import type { Route } from "@std/http/unstable-route"; 2 - import { oauthRoutes } from "./oauth.ts"; 3 - import { sliceRoutes } from "./slices.tsx"; 4 - import { dialogRoutes } from "./dialogs.tsx"; 5 - import { pageRoutes } from "./pages.tsx"; 6 - 7 - export const allRoutes: Route[] = [ 8 - ...oauthRoutes, 9 - ...sliceRoutes, 10 - ...dialogRoutes, 11 - ...pageRoutes, 12 - ];
+32
frontend/src/routes/mod.ts
··· 1 + import type { Route } from "@std/http/unstable-route"; 2 + import { landingRoutes } from "../features/landing/handlers.tsx"; 3 + import { authRoutes } from "../features/auth/handlers.tsx"; 4 + import { dashboardRoutes } from "../features/dashboard/handlers.tsx"; 5 + import { overviewRoutes, settingsRoutes as sliceSettingsRoutes, lexiconRoutes, recordsRoutes, codegenRoutes, oauthRoutes, apiDocsRoutes, syncRoutes, syncLogsRoutes, jetstreamRoutes } from "../features/slices/mod.ts"; 6 + import { settingsRoutes } from "../features/settings/handlers.tsx"; 7 + 8 + export const allRoutes: Route[] = [ 9 + // Landing page (public, no auth required) 10 + ...landingRoutes, 11 + 12 + // Auth routes (login, oauth, logout) 13 + ...authRoutes, 14 + 15 + // Dashboard routes (home page, create slice) 16 + ...dashboardRoutes, 17 + 18 + // User settings routes 19 + ...settingsRoutes, 20 + 21 + // Slice-specific routes 22 + ...overviewRoutes, 23 + ...sliceSettingsRoutes, 24 + ...lexiconRoutes, 25 + ...recordsRoutes, 26 + ...codegenRoutes, 27 + ...oauthRoutes, 28 + ...apiDocsRoutes, 29 + ...syncRoutes, 30 + ...syncLogsRoutes, 31 + ...jetstreamRoutes, 32 + ];
+48 -6
frontend/src/routes/oauth.ts frontend/src/features/auth/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 - import { atprotoClient, oauthSessions, sessionStore } from "../config.ts"; 2 + import { withAuth } from "../../routes/middleware.ts"; 3 + import { atprotoClient, oauthSessions, sessionStore } from "../../config.ts"; 4 + import { renderHTML } from "../../utils/render.tsx"; 5 + import { LoginPage } from "./templates/LoginPage.tsx"; 6 + 7 + // ============================================================================ 8 + // LOGIN PAGE HANDLER 9 + // ============================================================================ 10 + 11 + async function handleLoginPage(req: Request): Promise<Response> { 12 + const context = await withAuth(req); 13 + const url = new URL(req.url); 14 + 15 + const error = url.searchParams.get("error"); 16 + return renderHTML( 17 + <LoginPage error={error || undefined} currentUser={context.currentUser} /> 18 + ); 19 + } 20 + 21 + // ============================================================================ 22 + // OAUTH HANDLERS 23 + // ============================================================================ 3 24 4 25 async function handleOAuthAuthorize(req: Request): Promise<Response> { 5 26 try { ··· 83 104 // Create session cookie 84 105 const sessionCookie = sessionStore.createSessionCookie(sessionId); 85 106 107 + // Get user info from OAuth session 108 + let userInfo; 109 + try { 110 + userInfo = await atprotoClient.oauth?.getUserInfo(); 111 + } catch (error) { 112 + console.log("Failed to get user info:", error); 113 + } 114 + 86 115 // Sync external collections if user doesn't have them yet 87 116 try { 88 - // Get user info from OAuth session 89 - const userInfo = await atprotoClient.oauth?.getUserInfo(); 90 117 if (!userInfo?.sub) { 91 118 console.log( 92 119 "No user DID available, skipping external collections sync" ··· 97 124 const profileCheck = 98 125 await atprotoClient.app.bsky.actor.profile.getRecords({ 99 126 where: { 100 - did: { eq: userInfo.sub } 127 + did: { eq: userInfo.sub }, 101 128 }, 102 129 limit: 1, 103 130 }); ··· 123 150 ); 124 151 } 125 152 153 + // Redirect to user's profile page if handle is available 154 + const redirectPath = userInfo?.name ? `/profile/${userInfo.name}` : "/"; 155 + 126 156 return new Response(null, { 127 157 status: 302, 128 158 headers: { 129 - Location: new URL("/", req.url).toString(), 159 + Location: new URL(redirectPath, req.url).toString(), 130 160 "Set-Cookie": sessionCookie, 131 161 }, 132 162 }); ··· 163 193 }); 164 194 } 165 195 166 - export const oauthRoutes: Route[] = [ 196 + // ============================================================================ 197 + // ROUTE EXPORTS 198 + // ============================================================================ 199 + 200 + export const authRoutes: Route[] = [ 201 + // Login page 202 + { 203 + method: "GET", 204 + pattern: new URLPattern({ pathname: "/login" }), 205 + handler: handleLoginPage, 206 + }, 207 + // OAuth flow 167 208 { 168 209 method: "POST", 169 210 pattern: new URLPattern({ pathname: "/oauth/authorize" }), ··· 174 215 pattern: new URLPattern({ pathname: "/oauth/callback" }), 175 216 handler: handleOAuthCallback, 176 217 }, 218 + // Logout 177 219 { 178 220 method: "POST", 179 221 pattern: new URLPattern({ pathname: "/logout" }),
-728
frontend/src/routes/pages.tsx
··· 1 - import type { Route } from "@std/http/unstable-route"; 2 - import { render } from "preact-render-to-string"; 3 - import { withAuth } from "./middleware.ts"; 4 - import { atprotoClient } from "../config.ts"; 5 - import { getSliceClient } from "../utils/client.ts"; 6 - import { buildAtUri } from "../utils/at-uri.ts"; 7 - import { IndexPage } from "../pages/IndexPage.tsx"; 8 - import { LoginPage } from "../pages/LoginPage.tsx"; 9 - import { SlicePage } from "../pages/SlicePage.tsx"; 10 - import { SliceRecordsPage } from "../pages/SliceRecordsPage.tsx"; 11 - import { SliceSyncPage } from "../pages/SliceSyncPage.tsx"; 12 - import { SliceLexiconPage } from "../pages/SliceLexiconPage.tsx"; 13 - import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx"; 14 - import { SliceApiDocsPage } from "../pages/SliceApiDocsPage.tsx"; 15 - import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx"; 16 - import { SliceOAuthPage } from "../pages/SliceOAuthPage.tsx"; 17 - import { SyncJobLogsPage } from "../pages/SyncJobLogsPage.tsx"; 18 - import { JetstreamLogsPage } from "../pages/JetstreamLogsPage.tsx"; 19 - import { SettingsPage } from "../pages/SettingsPage.tsx"; 20 - import type { LogEntry } from "../client.ts"; 21 - 22 - async function handleIndexPage(req: Request): Promise<Response> { 23 - const context = await withAuth(req); 24 - 25 - let slices: Array<{ id: string; name: string; createdAt: string }> = []; 26 - 27 - if (context.currentUser.isAuthenticated) { 28 - try { 29 - const sliceRecords = await atprotoClient.social.slices.slice.getRecords({ 30 - sortBy: [{ field: "createdAt", direction: "desc" }], 31 - }); 32 - 33 - slices = sliceRecords.records.map((record) => { 34 - // Extract slice ID from URI 35 - const uriParts = record.uri.split("/"); 36 - const id = uriParts[uriParts.length - 1]; 37 - 38 - return { 39 - id, 40 - name: record.value.name, 41 - createdAt: record.value.createdAt, 42 - }; 43 - }); 44 - } catch (error) { 45 - console.error("Failed to fetch slices:", error); 46 - // Fall back to empty array if fetch fails 47 - } 48 - } 49 - 50 - const html = render( 51 - <IndexPage slices={slices} currentUser={context.currentUser} /> 52 - ); 53 - 54 - const responseHeaders: Record<string, string> = { 55 - "content-type": "text/html", 56 - }; 57 - 58 - return new Response(`<!DOCTYPE html>${html}`, { 59 - status: 200, 60 - headers: responseHeaders, 61 - }); 62 - } 63 - 64 - async function handleLoginPage(req: Request): Promise<Response> { 65 - const context = await withAuth(req); 66 - const url = new URL(req.url); 67 - 68 - // Login page with optional error message 69 - const error = url.searchParams.get("error"); 70 - const html = render( 71 - <LoginPage error={error || undefined} currentUser={context.currentUser} /> 72 - ); 73 - 74 - const responseHeaders: Record<string, string> = { 75 - "content-type": "text/html", 76 - }; 77 - 78 - return new Response(`<!DOCTYPE html>${html}`, { 79 - status: 200, 80 - headers: responseHeaders, 81 - }); 82 - } 83 - 84 - async function handleSlicePage( 85 - req: Request, 86 - params?: URLPatternResult 87 - ): Promise<Response> { 88 - const context = await withAuth(req); 89 - const sliceId = params?.pathname.groups.id; 90 - 91 - if (!sliceId) { 92 - return Response.redirect(new URL("/", req.url), 302); 93 - } 94 - 95 - let sliceData = { 96 - sliceId, 97 - sliceName: "Unknown Slice", 98 - totalRecords: 0, 99 - totalActors: 0, 100 - totalLexicons: 0, 101 - collections: [] as Array<{ name: string; count: number; actors?: number }>, 102 - }; 103 - 104 - if (context.currentUser.isAuthenticated) { 105 - try { 106 - const sliceUri = buildAtUri({ 107 - did: context.currentUser.sub || "unknown", 108 - collection: "social.slices.slice", 109 - rkey: sliceId, 110 - }); 111 - 112 - // Fetch slice record and stats in parallel 113 - const [sliceRecord, stats] = await Promise.all([ 114 - atprotoClient.social.slices.slice.getRecord({ uri: sliceUri }), 115 - atprotoClient.social.slices.slice.stats({ slice: sliceUri }), 116 - ]); 117 - 118 - const collections = stats.success 119 - ? stats.collectionStats.map((stat) => ({ 120 - name: stat.collection, 121 - count: stat.recordCount, 122 - actors: stat.uniqueActors, 123 - })) 124 - : []; 125 - 126 - sliceData = { 127 - sliceId, 128 - sliceName: sliceRecord.value.name, 129 - totalRecords: stats.success ? stats.totalRecords : 0, 130 - totalActors: stats.success ? stats.totalActors : 0, 131 - totalLexicons: stats.success ? stats.totalLexicons : 0, 132 - collections, 133 - }; 134 - } catch (error) { 135 - console.error("Failed to fetch slice data:", error); 136 - // Fall back to default data 137 - } 138 - } 139 - 140 - const html = render( 141 - <SlicePage 142 - {...sliceData} 143 - currentTab="overview" 144 - currentUser={context.currentUser} 145 - /> 146 - ); 147 - 148 - const responseHeaders: Record<string, string> = { 149 - "content-type": "text/html", 150 - }; 151 - 152 - return new Response(`<!DOCTYPE html>${html}`, { 153 - status: 200, 154 - headers: responseHeaders, 155 - }); 156 - } 157 - 158 - async function handleSliceTabPage( 159 - req: Request, 160 - params?: URLPatternResult 161 - ): Promise<Response> { 162 - const context = await withAuth(req); 163 - const sliceId = params?.pathname.groups.id; 164 - const tab = params?.pathname.groups.tab; 165 - 166 - if (!sliceId || !tab) { 167 - return Response.redirect(new URL("/", req.url), 302); 168 - } 169 - 170 - // Get real slice data from AT Protocol 171 - let sliceData = { 172 - sliceId, 173 - sliceName: "Unknown Slice", 174 - sliceDomain: "", 175 - totalRecords: 0, 176 - collections: [] as Array<{ name: string; count: number }>, 177 - }; 178 - 179 - if (context.currentUser.isAuthenticated) { 180 - try { 181 - // Construct the full URI for this slice 182 - const sliceUri = buildAtUri({ 183 - did: context.currentUser.sub ?? "unknown", 184 - collection: "social.slices.slice", 185 - rkey: sliceId, 186 - }); 187 - 188 - // Fetch slice record and stats in parallel 189 - const [sliceRecord, stats] = await Promise.all([ 190 - atprotoClient.social.slices.slice.getRecord({ uri: sliceUri }), 191 - atprotoClient.social.slices.slice.stats({ slice: sliceUri }), 192 - ]); 193 - 194 - // Transform collection stats to match the interface 195 - const collections = stats.success 196 - ? stats.collectionStats.map((stat) => ({ 197 - name: stat.collection, 198 - count: stat.recordCount, 199 - })) 200 - : []; 201 - 202 - sliceData = { 203 - sliceId, 204 - sliceName: sliceRecord.value.name, 205 - sliceDomain: sliceRecord.value.domain || "", 206 - totalRecords: stats.success ? stats.totalRecords : 0, 207 - collections, 208 - }; 209 - } catch (error) { 210 - console.error("Failed to fetch slice:", error); 211 - // Fall back to default data 212 - } 213 - } 214 - 215 - let html: string; 216 - 217 - switch (tab) { 218 - case "records": { 219 - // Get URL parameters for collection, author, and search filtering 220 - const url = new URL(req.url); 221 - const selectedCollection = url.searchParams.get("collection") || ""; 222 - const selectedAuthor = url.searchParams.get("author") || ""; 223 - const searchQuery = url.searchParams.get("search") || ""; 224 - 225 - // Fetch real records if a collection is selected 226 - let records: Array<{ 227 - uri: string; 228 - indexedAt: string; 229 - collection: string; 230 - did: string; 231 - cid: string; 232 - value: unknown; 233 - pretty_value: string; 234 - }> = []; 235 - if ( 236 - (selectedCollection || searchQuery) && 237 - sliceData.collections.length > 0 238 - ) { 239 - try { 240 - // Use slice-specific client to ensure correct slice URI 241 - const sliceClient = getSliceClient(context, sliceId); 242 - const recordsResult = 243 - await sliceClient.social.slices.slice.getSliceRecords({ 244 - where: { 245 - ...(selectedCollection && { 246 - collection: { eq: selectedCollection }, 247 - }), 248 - ...(searchQuery && { json: { contains: searchQuery } }), 249 - ...(selectedAuthor && { did: { eq: selectedAuthor } }), 250 - }, 251 - limit: 20, 252 - }); 253 - 254 - if (recordsResult.success) { 255 - records = recordsResult.records.map((record) => ({ 256 - uri: record.uri, 257 - indexedAt: record.indexedAt, 258 - collection: record.collection, 259 - did: record.did, 260 - cid: record.cid, 261 - value: record.value, 262 - pretty_value: JSON.stringify(record.value, null, 2), 263 - })); 264 - } 265 - } catch (error) { 266 - console.error("Failed to fetch records:", error); 267 - } 268 - } 269 - 270 - const recordsData = { 271 - ...sliceData, 272 - records, 273 - collection: selectedCollection, 274 - author: selectedAuthor, 275 - search: searchQuery, 276 - availableCollections: sliceData.collections, 277 - }; 278 - html = render( 279 - <SliceRecordsPage {...recordsData} currentUser={context.currentUser} /> 280 - ); 281 - break; 282 - } 283 - 284 - case "sync": { 285 - // Fetch slice stats to get available collections for prefilling 286 - const primaryCollections: string[] = []; 287 - const externalCollections: string[] = []; 288 - 289 - try { 290 - const sliceUri = buildAtUri({ 291 - did: context.currentUser.sub ?? "unknown", 292 - collection: "social.slices.slice", 293 - rkey: sliceId, 294 - }); 295 - 296 - const stats = await atprotoClient.social.slices.slice.stats({ 297 - slice: sliceUri, 298 - }); 299 - 300 - if (stats.success) { 301 - const sliceDomain = sliceData.sliceDomain || ""; 302 - 303 - // Categorize collections by domain 304 - stats.collections.forEach((collection) => { 305 - if (sliceDomain && collection.startsWith(sliceDomain)) { 306 - primaryCollections.push(collection); 307 - } else { 308 - externalCollections.push(collection); 309 - } 310 - }); 311 - } 312 - } catch (error) { 313 - console.error("Failed to fetch slice stats:", error); 314 - // Will use empty collections arrays as fallback 315 - } 316 - 317 - html = render( 318 - <SliceSyncPage 319 - {...sliceData} 320 - collections={primaryCollections} 321 - externalCollections={externalCollections} 322 - currentUser={context.currentUser} 323 - /> 324 - ); 325 - break; 326 - } 327 - 328 - case "lexicon": { 329 - const lexiconData = { 330 - ...sliceData, 331 - lexicons: [ 332 - { 333 - nsid: "com.chadtmiller.slice", 334 - updated_at: "2024-01-15 10:30:00", 335 - pretty_definitions: `{\n "lexicon": 1,\n "id": "com.chadtmiller.slice",\n "defs": {\n "main": {\n "type": "record",\n "description": "Slice application record type"\n }\n }\n}`, 336 - }, 337 - ], 338 - }; 339 - html = render( 340 - <SliceLexiconPage {...lexiconData} currentUser={context.currentUser} /> 341 - ); 342 - break; 343 - } 344 - 345 - case "codegen": { 346 - const codegenData = { 347 - ...sliceData, 348 - lexicons: [ 349 - { nsid: "com.chadtmiller.slice" }, 350 - { nsid: "social.grain.gallery" }, 351 - ], 352 - }; 353 - html = render( 354 - <SliceCodegenPage {...codegenData} currentUser={context.currentUser} /> 355 - ); 356 - break; 357 - } 358 - 359 - case "settings": { 360 - const url = new URL(req.url); 361 - const updated = url.searchParams.get("updated"); 362 - const error = url.searchParams.get("error"); 363 - 364 - html = render( 365 - <SliceSettingsPage 366 - {...sliceData} 367 - updated={updated === "true"} 368 - error={error} 369 - currentUser={context.currentUser} 370 - /> 371 - ); 372 - break; 373 - } 374 - 375 - default: 376 - // 404 for unknown slice subpaths 377 - return Response.redirect(new URL("/", req.url), 302); 378 - } 379 - 380 - const responseHeaders: Record<string, string> = { 381 - "content-type": "text/html", 382 - }; 383 - 384 - return new Response(`<!DOCTYPE html>${html}`, { 385 - status: 200, 386 - headers: responseHeaders, 387 - }); 388 - } 389 - 390 - async function handleSliceApiDocsPage( 391 - req: Request, 392 - params?: URLPatternResult 393 - ): Promise<Response> { 394 - const context = await withAuth(req); 395 - const sliceId = params?.pathname.groups.id; 396 - 397 - if (!sliceId) { 398 - return Response.redirect(new URL("/", req.url), 302); 399 - } 400 - 401 - // Get OAuth access token directly from OAuth client (clean separation) 402 - let accessToken: string | undefined; 403 - try { 404 - // Tokens are managed by @slices/oauth, not stored in sessions 405 - const tokens = await atprotoClient.oauth?.ensureValidToken(); 406 - accessToken = tokens?.accessToken; 407 - } catch (error) { 408 - console.log("Could not get OAuth token:", error); 409 - } 410 - 411 - // Get real slice data from AT Protocol 412 - let sliceData = { 413 - sliceId, 414 - sliceName: "Unknown Slice", 415 - accessToken, 416 - }; 417 - 418 - if (context.currentUser.isAuthenticated) { 419 - try { 420 - const sliceUri = buildAtUri({ 421 - did: context.currentUser.sub!, 422 - collection: "social.slices.slice", 423 - rkey: sliceId, 424 - }); 425 - 426 - const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ 427 - uri: sliceUri, 428 - }); 429 - 430 - sliceData = { 431 - sliceId, 432 - sliceName: sliceRecord.value.name, 433 - accessToken, 434 - }; 435 - } catch (error) { 436 - console.error("Failed to fetch slice data:", error); 437 - // Fall back to default data 438 - } 439 - } 440 - 441 - const html = render( 442 - <SliceApiDocsPage {...sliceData} currentUser={context.currentUser} /> 443 - ); 444 - 445 - const responseHeaders: Record<string, string> = { 446 - "content-type": "text/html", 447 - }; 448 - 449 - return new Response(`<!DOCTYPE html>${html}`, { 450 - status: 200, 451 - headers: responseHeaders, 452 - }); 453 - } 454 - 455 - async function handleSettingsPage(req: Request): Promise<Response> { 456 - const context = await withAuth(req); 457 - 458 - if (!context.currentUser.isAuthenticated) { 459 - return Response.redirect(new URL("/login", req.url), 302); 460 - } 461 - 462 - // Try to fetch existing profile 463 - let profile: 464 - | { 465 - displayName?: string; 466 - description?: string; 467 - avatar?: string; 468 - } 469 - | undefined; 470 - 471 - try { 472 - const profileRecord = 473 - await atprotoClient.social.slices.actor.profile.getRecord({ 474 - uri: buildAtUri({ 475 - did: context.currentUser.sub!, 476 - collection: "social.slices.actor.profile", 477 - rkey: "self", 478 - }), 479 - }); 480 - if (profileRecord) { 481 - profile = { 482 - displayName: profileRecord.value.displayName, 483 - description: profileRecord.value.description, 484 - avatar: profileRecord.value.avatar?.toString(), // Convert Blob to string representation 485 - }; 486 - } 487 - } catch (error) { 488 - console.error("Failed to fetch profile:", error); 489 - // Continue without profile data 490 - } 491 - 492 - const html = render( 493 - <SettingsPage profile={profile} currentUser={context.currentUser} /> 494 - ); 495 - 496 - const responseHeaders: Record<string, string> = { 497 - "content-type": "text/html", 498 - }; 499 - 500 - return new Response(`<!DOCTYPE html>${html}`, { 501 - status: 200, 502 - headers: responseHeaders, 503 - }); 504 - } 505 - 506 - async function handleSyncJobLogsPage( 507 - req: Request, 508 - params?: URLPatternResult 509 - ): Promise<Response> { 510 - const context = await withAuth(req); 511 - 512 - if (!context.currentUser.isAuthenticated) { 513 - return Response.redirect(new URL("/login", req.url), 302); 514 - } 515 - 516 - const sliceId = params?.pathname.groups.id; 517 - const jobId = params?.pathname.groups.jobId; 518 - 519 - if (!sliceId || !jobId) { 520 - return new Response("Invalid slice ID or job ID", { status: 400 }); 521 - } 522 - 523 - // Get slice details to pass slice name 524 - let slice: { name: string } = { name: "Unknown Slice" }; 525 - try { 526 - const sliceClient = getSliceClient(context, sliceId); 527 - const sliceRecord = await sliceClient.social.slices.slice.getRecord({ 528 - uri: buildAtUri({ 529 - did: context.currentUser.sub!, 530 - collection: "social.slices.slice", 531 - rkey: sliceId, 532 - }), 533 - }); 534 - if (sliceRecord) { 535 - slice = { name: sliceRecord.value.name }; 536 - } 537 - } catch (error) { 538 - console.error("Failed to fetch slice:", error); 539 - } 540 - 541 - const html = render( 542 - <SyncJobLogsPage 543 - sliceName={slice.name} 544 - sliceId={sliceId} 545 - jobId={jobId} 546 - currentUser={context.currentUser} 547 - /> 548 - ); 549 - 550 - const responseHeaders: Record<string, string> = { 551 - "content-type": "text/html", 552 - }; 553 - 554 - return new Response(`<!DOCTYPE html>${html}`, { 555 - status: 200, 556 - headers: responseHeaders, 557 - }); 558 - } 559 - 560 - async function handleJetstreamLogsPage( 561 - req: Request, 562 - params?: URLPatternResult 563 - ): Promise<Response> { 564 - const context = await withAuth(req); 565 - 566 - if (!context.currentUser.isAuthenticated) { 567 - return Response.redirect(new URL("/login", req.url), 302); 568 - } 569 - 570 - const sliceId = params?.pathname.groups.id; 571 - 572 - if (!sliceId) { 573 - return new Response("Invalid slice ID", { status: 400 }); 574 - } 575 - 576 - // Fetch Jetstream logs 577 - let logs: LogEntry[] = []; 578 - 579 - try { 580 - const sliceClient = getSliceClient(context, sliceId); 581 - 582 - const logsResult = await sliceClient.social.slices.slice.getJetstreamLogs({ 583 - limit: 100, 584 - }); 585 - logs = logsResult.logs.sort( 586 - (a, b) => 587 - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 588 - ); 589 - } catch (error) { 590 - console.error("Failed to fetch Jetstream logs:", error); 591 - } 592 - 593 - const html = render( 594 - <JetstreamLogsPage 595 - logs={logs} 596 - sliceId={sliceId} 597 - currentUser={context.currentUser} 598 - /> 599 - ); 600 - 601 - const responseHeaders: Record<string, string> = { 602 - "content-type": "text/html", 603 - }; 604 - 605 - return new Response(`<!DOCTYPE html>${html}`, { 606 - status: 200, 607 - headers: responseHeaders, 608 - }); 609 - } 610 - 611 - async function handleSliceOAuthPage( 612 - req: Request, 613 - params?: URLPatternResult 614 - ): Promise<Response> { 615 - const context = await withAuth(req); 616 - if (!context.currentUser.isAuthenticated) { 617 - return new Response("", { 618 - status: 302, 619 - headers: { location: "/login" }, 620 - }); 621 - } 622 - 623 - const sliceId = params?.pathname.groups.id; 624 - if (!sliceId) { 625 - return new Response("Invalid slice ID", { status: 400 }); 626 - } 627 - 628 - // Get the slice record first (separate from OAuth clients) 629 - const sliceUri = buildAtUri({ 630 - did: context.currentUser.sub!, 631 - collection: "social.slices.slice", 632 - rkey: sliceId, 633 - }); 634 - 635 - const sliceClient = getSliceClient(context, sliceId); 636 - 637 - let slice; 638 - try { 639 - slice = await atprotoClient.social.slices.slice.getRecord({ 640 - uri: sliceUri, 641 - }); 642 - } catch (error) { 643 - console.error("Error fetching slice:", error); 644 - return new Response("Slice not found", { status: 404 }); 645 - } 646 - 647 - // Try to fetch OAuth clients 648 - let clientsWithDetails: { 649 - clientId: string; 650 - createdAt: string; 651 - clientName?: string; 652 - redirectUris?: string[]; 653 - }[] = []; 654 - let errorMessage = null; 655 - 656 - try { 657 - const oauthClientsResponse = 658 - await sliceClient.social.slices.slice.getOAuthClients(); 659 - console.log("Fetched OAuth clients:", oauthClientsResponse.clients); 660 - clientsWithDetails = oauthClientsResponse.clients.map((client) => ({ 661 - clientId: client.clientId, 662 - createdAt: new Date().toISOString(), // Backend should provide this 663 - clientName: client.clientName, 664 - redirectUris: client.redirectUris, 665 - })); 666 - } catch (oauthError) { 667 - console.error("Error fetching OAuth clients:", oauthError); 668 - errorMessage = "Failed to fetch OAuth clients"; 669 - } 670 - 671 - const html = render( 672 - <SliceOAuthPage 673 - sliceName={slice.value.name} 674 - sliceId={sliceId} 675 - clients={clientsWithDetails} 676 - currentUser={context.currentUser} 677 - error={errorMessage} 678 - /> 679 - ); 680 - 681 - const responseHeaders: Record<string, string> = { 682 - "content-type": "text/html", 683 - }; 684 - 685 - return new Response(`<!DOCTYPE html>${html}`, { 686 - status: 200, 687 - headers: responseHeaders, 688 - }); 689 - } 690 - 691 - export const pageRoutes: Route[] = [ 692 - { 693 - pattern: new URLPattern({ pathname: "/" }), 694 - handler: handleIndexPage, 695 - }, 696 - { 697 - pattern: new URLPattern({ pathname: "/login" }), 698 - handler: handleLoginPage, 699 - }, 700 - { 701 - pattern: new URLPattern({ pathname: "/settings" }), 702 - handler: handleSettingsPage, 703 - }, 704 - { 705 - pattern: new URLPattern({ pathname: "/slices/:id" }), 706 - handler: handleSlicePage, 707 - }, 708 - { 709 - pattern: new URLPattern({ pathname: "/slices/:id/api-docs" }), 710 - handler: handleSliceApiDocsPage, 711 - }, 712 - { 713 - pattern: new URLPattern({ pathname: "/slices/:id/sync/logs/:jobId" }), 714 - handler: handleSyncJobLogsPage, 715 - }, 716 - { 717 - pattern: new URLPattern({ pathname: "/slices/:id/jetstream/logs" }), 718 - handler: handleJetstreamLogsPage, 719 - }, 720 - { 721 - pattern: new URLPattern({ pathname: "/slices/:id/oauth" }), 722 - handler: handleSliceOAuthPage, 723 - }, 724 - { 725 - pattern: new URLPattern({ pathname: "/slices/:id/:tab" }), 726 - handler: handleSliceTabPage, 727 - }, 728 - ];
-1466
frontend/src/routes/slices.tsx
··· 1 - import type { Route } from "@std/http/unstable-route"; 2 - import { render } from "preact-render-to-string"; 3 - import { withAuth, requireAuth } from "./middleware.ts"; 4 - import { atprotoClient } from "../config.ts"; 5 - import { getSliceClient } from "../utils/client.ts"; 6 - import { buildSliceUri } from "../utils/at-uri.ts"; 7 - import type { SocialSlicesActorProfile } from "../client.ts"; 8 - import { CreateSliceDialog } from "../components/CreateSliceDialog.tsx"; 9 - import { UpdateResult } from "../components/UpdateResult.tsx"; 10 - import { EmptyLexiconState } from "../components/EmptyLexiconState.tsx"; 11 - import { LexiconSuccessMessage } from "../components/LexiconSuccessMessage.tsx"; 12 - import { LexiconErrorMessage } from "../components/LexiconErrorMessage.tsx"; 13 - import { LexiconViewModal } from "../components/LexiconViewModal.tsx"; 14 - import { LexiconListItem } from "../components/LexiconListItem.tsx"; 15 - import { CodegenResult } from "../components/CodegenResult.tsx"; 16 - import { SettingsResult } from "../components/SettingsResult.tsx"; 17 - import { SyncResult } from "../components/SyncResult.tsx"; 18 - import { JobHistory } from "../components/JobHistory.tsx"; 19 - import { JetstreamStatus } from "../components/JetstreamStatus.tsx"; 20 - import { SyncJobLogs } from "../components/SyncJobLogs.tsx"; 21 - import { JetstreamLogs } from "../components/JetstreamLogs.tsx"; 22 - import { buildAtUri } from "../utils/at-uri.ts"; 23 - import { Layout } from "../components/Layout.tsx"; 24 - import { OAuthClientModal } from "../components/OAuthClientModal.tsx"; 25 - import { OAuthRegistrationResult } from "../components/OAuthRegistrationResult.tsx"; 26 - import { OAuthDeleteResult } from "../components/OAuthDeleteResult.tsx"; 27 - 28 - async function handleCreateSlice(req: Request): Promise<Response> { 29 - const context = await withAuth(req); 30 - const authResponse = requireAuth(context); 31 - if (authResponse) return authResponse; 32 - 33 - // Ensure client has tokens before attempting API calls 34 - const authInfo = await atprotoClient.oauth?.getAuthenticationInfo(); 35 - if (!authInfo?.isAuthenticated) { 36 - const dialogHtml = render( 37 - <CreateSliceDialog error="Session expired. Please log in again." /> 38 - ); 39 - return new Response(dialogHtml, { 40 - status: 200, 41 - headers: { "content-type": "text/html" }, 42 - }); 43 - } 44 - 45 - try { 46 - const formData = await req.formData(); 47 - const name = formData.get("name") as string; 48 - const domain = formData.get("domain") as string; 49 - 50 - if (!name || name.trim().length === 0) { 51 - const dialogHtml = render( 52 - <CreateSliceDialog 53 - error="Slice name is required" 54 - name={name} 55 - domain={domain} 56 - /> 57 - ); 58 - return new Response(dialogHtml, { 59 - status: 200, 60 - headers: { "content-type": "text/html" }, 61 - }); 62 - } 63 - 64 - if (!domain || domain.trim().length === 0) { 65 - const dialogHtml = render( 66 - <CreateSliceDialog 67 - error="Primary domain is required" 68 - name={name} 69 - domain={domain} 70 - /> 71 - ); 72 - return new Response(dialogHtml, { 73 - status: 200, 74 - headers: { "content-type": "text/html" }, 75 - }); 76 - } 77 - 78 - // Create actual slice using AT Protocol 79 - try { 80 - const recordData = { 81 - name: name.trim(), 82 - domain: domain.trim(), 83 - createdAt: new Date().toISOString(), 84 - }; 85 - 86 - const result = await atprotoClient.social.slices.slice.createRecord( 87 - recordData 88 - ); 89 - 90 - // Extract record key from URI (format: at://did:plc:example/social.slices.slice/rkey) 91 - const uriParts = result.uri.split("/"); 92 - const sliceId = uriParts[uriParts.length - 1]; 93 - 94 - return new Response("", { 95 - status: 200, 96 - headers: { 97 - "HX-Redirect": `/slices/${sliceId}`, 98 - }, 99 - }); 100 - } catch (_createError) { 101 - const dialogHtml = render( 102 - <CreateSliceDialog 103 - error="Failed to create slice record. Please try again." 104 - name={name} 105 - domain={domain} 106 - /> 107 - ); 108 - return new Response(dialogHtml, { 109 - status: 200, 110 - headers: { "content-type": "text/html" }, 111 - }); 112 - } 113 - } catch (_error) { 114 - const dialogHtml = render( 115 - <CreateSliceDialog error="Failed to create slice" /> 116 - ); 117 - return new Response(dialogHtml, { 118 - status: 200, 119 - headers: { "content-type": "text/html" }, 120 - }); 121 - } 122 - } 123 - 124 - async function handleUpdateSliceSettings( 125 - req: Request, 126 - params?: URLPatternResult 127 - ): Promise<Response> { 128 - const context = await withAuth(req); 129 - const authResponse = requireAuth(context); 130 - if (authResponse) return authResponse; 131 - 132 - const sliceId = params?.pathname.groups.id; 133 - if (!sliceId) { 134 - return new Response("Invalid slice ID", { status: 400 }); 135 - } 136 - 137 - try { 138 - const formData = await req.formData(); 139 - const name = formData.get("name") as string; 140 - const domain = formData.get("domain") as string; 141 - 142 - if (!name || name.trim().length === 0) { 143 - const resultHtml = render( 144 - <UpdateResult type="error" message="Slice name is required" /> 145 - ); 146 - return new Response(resultHtml, { 147 - status: 200, 148 - headers: { "content-type": "text/html" }, 149 - }); 150 - } 151 - 152 - if (!domain || domain.trim().length === 0) { 153 - const resultHtml = render( 154 - <UpdateResult type="error" message="Primary domain is required" /> 155 - ); 156 - return new Response(resultHtml, { 157 - status: 200, 158 - headers: { "content-type": "text/html" }, 159 - }); 160 - } 161 - 162 - // Construct the URI for this slice 163 - const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 164 - 165 - // Get the current record first 166 - const currentRecord = await atprotoClient.social.slices.slice.getRecord({ 167 - uri: sliceUri, 168 - }); 169 - 170 - // Update the record with new name and domain 171 - const updatedRecord = { 172 - ...currentRecord.value, 173 - name: name.trim(), 174 - domain: domain.trim(), 175 - }; 176 - 177 - await atprotoClient.social.slices.slice.updateRecord( 178 - sliceId, 179 - updatedRecord 180 - ); 181 - 182 - return new Response("", { 183 - status: 200, 184 - headers: { 185 - "HX-Redirect": `/slices/${sliceId}/settings?updated=true`, 186 - }, 187 - }); 188 - } catch (_error) { 189 - return new Response("", { 190 - status: 200, 191 - headers: { 192 - "HX-Redirect": `/slices/${sliceId}/settings?error=update_failed`, 193 - }, 194 - }); 195 - } 196 - } 197 - 198 - async function handleDeleteSlice( 199 - req: Request, 200 - params?: URLPatternResult 201 - ): Promise<Response> { 202 - const context = await withAuth(req); 203 - const authResponse = requireAuth(context); 204 - if (authResponse) return authResponse; 205 - 206 - const sliceId = params?.pathname.groups.id; 207 - if (!sliceId) { 208 - return new Response("Invalid slice ID", { status: 400 }); 209 - } 210 - 211 - try { 212 - // Delete the slice record from AT Protocol 213 - await atprotoClient.social.slices.slice.deleteRecord(sliceId); 214 - 215 - // Redirect to home page 216 - return new Response("", { 217 - status: 200, 218 - headers: { 219 - "HX-Redirect": "/", 220 - }, 221 - }); 222 - } catch (_error) { 223 - return new Response("Failed to delete slice", { status: 500 }); 224 - } 225 - } 226 - 227 - async function handleListLexicons( 228 - req: Request, 229 - params?: URLPatternResult 230 - ): Promise<Response> { 231 - const context = await withAuth(req); 232 - const authResponse = requireAuth(context); 233 - if (authResponse) return authResponse; 234 - 235 - const sliceId = params?.pathname.groups.id; 236 - if (!sliceId) { 237 - return new Response("Invalid slice ID", { status: 400 }); 238 - } 239 - 240 - try { 241 - // Get slice-specific client and fetch lexicons 242 - const sliceClient = getSliceClient(context, sliceId); 243 - const lexiconRecords = await sliceClient.social.slices.lexicon.getRecords(); 244 - 245 - if (lexiconRecords.records.length === 0) { 246 - const html = render(<EmptyLexiconState />); 247 - return new Response(html, { 248 - status: 200, 249 - headers: { "content-type": "text/html" }, 250 - }); 251 - } 252 - 253 - const html = render( 254 - <div className="space-y-0"> 255 - {lexiconRecords.records.map((record) => ( 256 - <LexiconListItem 257 - key={record.uri} 258 - nsid={record.value.nsid} 259 - uri={record.uri} 260 - createdAt={record.value.createdAt} 261 - sliceId={sliceId} 262 - /> 263 - ))} 264 - </div> 265 - ); 266 - 267 - return new Response(html, { 268 - status: 200, 269 - headers: { "content-type": "text/html" }, 270 - }); 271 - } catch (error) { 272 - console.error("Failed to fetch lexicons:", error); 273 - const html = render( 274 - <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 275 - <p>Failed to load lexicons: {error}</p> 276 - </div> 277 - ); 278 - return new Response(html, { 279 - status: 500, 280 - headers: { "content-type": "text/html" }, 281 - }); 282 - } 283 - } 284 - 285 - async function handleCreateLexicon(req: Request): Promise<Response> { 286 - const context = await withAuth(req); 287 - const authResponse = requireAuth(context); 288 - if (authResponse) return authResponse; 289 - 290 - try { 291 - const formData = await req.formData(); 292 - const lexiconJson = formData.get("lexicon_json") as string; 293 - 294 - if (!lexiconJson || lexiconJson.trim().length === 0) { 295 - const html = render( 296 - <LexiconErrorMessage error="Lexicon JSON is required" /> 297 - ); 298 - return new Response(html, { 299 - status: 400, 300 - headers: { "content-type": "text/html" }, 301 - }); 302 - } 303 - 304 - // Parse the lexicon JSON 305 - let lexiconData; 306 - try { 307 - lexiconData = JSON.parse(lexiconJson); 308 - } catch (parseError) { 309 - const html = render( 310 - <LexiconErrorMessage 311 - error={`Failed to parse lexicon JSON: ${parseError}`} 312 - /> 313 - ); 314 - return new Response(html, { 315 - status: 200, 316 - headers: { "content-type": "text/html" }, 317 - }); 318 - } 319 - 320 - // Basic validation of required fields 321 - if (!lexiconData.id && !lexiconData.nsid) { 322 - const html = render( 323 - <LexiconErrorMessage error="Lexicon must have an 'id' field (e.g., 'com.example.myLexicon')" /> 324 - ); 325 - return new Response(html, { 326 - status: 200, // Return 200 so HTMX displays the error 327 - headers: { "content-type": "text/html" }, 328 - }); 329 - } 330 - 331 - if (!lexiconData.defs && !lexiconData.definitions) { 332 - const html = render( 333 - <LexiconErrorMessage error="Lexicon must have a 'defs' field containing the schema definitions" /> 334 - ); 335 - return new Response(html, { 336 - status: 200, // Return 200 so HTMX displays the error 337 - headers: { "content-type": "text/html" }, 338 - }); 339 - } 340 - 341 - // Create the lexicon record 342 - try { 343 - // Extract slice ID from URL if this is a slice-specific request 344 - const url = new URL(req.url); 345 - const pathParts = url.pathname.split("/"); 346 - let sliceId = "example"; // fallback 347 - 348 - // Check if this is a slice-specific request (/api/slices/:id/lexicons) 349 - if ( 350 - pathParts.length >= 4 && 351 - pathParts[1] === "api" && 352 - pathParts[2] === "slices" 353 - ) { 354 - sliceId = pathParts[3]; 355 - } 356 - 357 - const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 358 - 359 - const lexiconRecord = { 360 - nsid: lexiconData.id, 361 - definitions: JSON.stringify(lexiconData.defs || lexiconData), 362 - createdAt: new Date().toISOString(), 363 - slice: sliceUri, 364 - }; 365 - 366 - // Use slice-specific client for creating lexicon 367 - const sliceClient = getSliceClient(context, sliceId); 368 - const result = await sliceClient.social.slices.lexicon.createRecord( 369 - lexiconRecord 370 - ); 371 - 372 - const html = render( 373 - <LexiconSuccessMessage 374 - nsid={lexiconRecord.nsid} 375 - uri={result.uri} 376 - sliceId={sliceId} 377 - /> 378 - ); 379 - return new Response(html, { 380 - status: 200, 381 - headers: { "content-type": "text/html" }, 382 - }); 383 - } catch (createError) { 384 - let errorMessage = `Failed to create lexicon: ${createError}`; 385 - 386 - // Check if this is a structured error response from the API 387 - if (createError instanceof Error) { 388 - try { 389 - // Try to parse the error message as JSON (from API response) 390 - const errorResponse = JSON.parse(createError.message); 391 - if ( 392 - errorResponse.error === "ValidationError" && 393 - errorResponse.message 394 - ) { 395 - errorMessage = errorResponse.message; 396 - } 397 - } catch { 398 - // If not JSON, check for common validation error patterns in the string 399 - const errorStr = createError.message; 400 - if (errorStr.includes("Invalid JSON in definitions field")) { 401 - errorMessage = 402 - "The lexicon definitions contain invalid JSON. Please check your JSON syntax."; 403 - } else if (errorStr.includes("must be camelCase")) { 404 - errorMessage = 405 - 'Definition names must be camelCase (letters and numbers only). Examples: "main", "listView", "aspectRatio"'; 406 - } else if (errorStr.includes("missing required 'type' field")) { 407 - errorMessage = 408 - 'Each lexicon definition must have a "type" field. Valid types include: "record", "object", "string", "integer", "boolean", "array", "union", "ref", "blob", "bytes", "cid-link", "unknown"'; 409 - } else if (errorStr.includes("Lexicon validation failed")) { 410 - errorMessage = errorStr; // Use the full validation error message 411 - } 412 - } 413 - } 414 - 415 - const html = render(<LexiconErrorMessage error={errorMessage} />); 416 - return new Response(html, { 417 - status: 200, // Return 200 so HTMX displays the error 418 - headers: { "content-type": "text/html" }, 419 - }); 420 - } 421 - } catch (error) { 422 - const html = render( 423 - <LexiconErrorMessage error={`Server error: ${error}`} /> 424 - ); 425 - return new Response(html, { 426 - status: 500, 427 - headers: { "content-type": "text/html" }, 428 - }); 429 - } 430 - } 431 - 432 - async function handleViewLexicon( 433 - req: Request, 434 - params?: URLPatternResult 435 - ): Promise<Response> { 436 - const context = await withAuth(req); 437 - const authResponse = requireAuth(context); 438 - if (authResponse) return authResponse; 439 - 440 - const sliceId = params?.pathname.groups.id; 441 - const rkey = params?.pathname.groups.rkey; 442 - if (!sliceId || !rkey) { 443 - return new Response("Invalid slice ID or lexicon key", { status: 400 }); 444 - } 445 - 446 - try { 447 - // Get slice-specific client and fetch the specific lexicon 448 - const sliceClient = getSliceClient(context, sliceId); 449 - const lexiconRecords = await sliceClient.social.slices.lexicon.getRecords(); 450 - 451 - // Find the lexicon with matching rkey 452 - const lexicon = lexiconRecords.records.find((record) => 453 - record.uri.endsWith(`/${rkey}`) 454 - ); 455 - 456 - if (!lexicon) { 457 - return new Response("Lexicon not found", { status: 404 }); 458 - } 459 - 460 - const component = await LexiconViewModal({ 461 - nsid: lexicon.value.nsid, 462 - definitions: lexicon.value.definitions, 463 - uri: lexicon.uri, 464 - createdAt: lexicon.indexedAt, 465 - }); 466 - const html = render(component); 467 - 468 - return new Response(html, { 469 - status: 200, 470 - headers: { "content-type": "text/html" }, 471 - }); 472 - } catch (error) { 473 - console.error("Error viewing lexicon:", error); 474 - return new Response("Failed to load lexicon", { status: 500 }); 475 - } 476 - } 477 - 478 - async function handleDeleteLexicon( 479 - req: Request, 480 - params?: URLPatternResult 481 - ): Promise<Response> { 482 - const context = await withAuth(req); 483 - const authResponse = requireAuth(context); 484 - if (authResponse) return authResponse; 485 - 486 - const sliceId = params?.pathname.groups.id; 487 - const rkey = params?.pathname.groups.rkey; 488 - if (!sliceId || !rkey) { 489 - return new Response("Invalid slice ID or lexicon ID", { status: 400 }); 490 - } 491 - 492 - try { 493 - // Use slice-specific client for deleting lexicon 494 - const sliceClient = getSliceClient(context, sliceId); 495 - await sliceClient.social.slices.lexicon.deleteRecord(rkey); 496 - 497 - // Check if there are any remaining lexicons 498 - const remainingLexicons = 499 - await sliceClient.social.slices.lexicon.getRecords(); 500 - 501 - if (remainingLexicons.records.length === 0) { 502 - // If no lexicons remain, return the empty state and target the parent list 503 - const html = render(<EmptyLexiconState withPadding />); 504 - 505 - return new Response(html, { 506 - status: 200, 507 - headers: { 508 - "content-type": "text/html", 509 - "HX-Retarget": "#lexicon-list", 510 - }, 511 - }); 512 - } else { 513 - // Just remove this specific item 514 - return new Response("", { 515 - status: 200, 516 - headers: { "content-type": "text/html" }, 517 - }); 518 - } 519 - } catch (error) { 520 - console.error("Failed to delete lexicon:", error); 521 - const html = render( 522 - <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 523 - <p>Failed to delete lexicon: {error}</p> 524 - </div> 525 - ); 526 - return new Response(html, { 527 - status: 500, 528 - headers: { "content-type": "text/html" }, 529 - }); 530 - } 531 - } 532 - 533 - async function handleSliceCodegen( 534 - req: Request, 535 - params?: URLPatternResult 536 - ): Promise<Response> { 537 - const context = await withAuth(req); 538 - const authResponse = requireAuth(context); 539 - if (authResponse) return authResponse; 540 - 541 - const sliceId = params?.pathname.groups.id; 542 - if (!sliceId) { 543 - const component = await CodegenResult({ 544 - success: false, 545 - error: "Invalid slice ID", 546 - }); 547 - const html = render(component); 548 - return new Response(html, { 549 - status: 400, 550 - headers: { "content-type": "text/html" }, 551 - }); 552 - } 553 - 554 - try { 555 - // Parse form data 556 - const formData = await req.formData(); 557 - const target = formData.get("format") || "typescript"; 558 - 559 - // Construct the slice URI 560 - const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 561 - 562 - // Use the slice-specific client 563 - const sliceClient = getSliceClient(context, sliceId); 564 - 565 - // Call the codegen XRPC endpoint 566 - const result = await sliceClient.social.slices.slice.codegen({ 567 - target: target as string, 568 - slice: sliceUri, 569 - }); 570 - 571 - const component = await CodegenResult({ 572 - success: result.success, 573 - generatedCode: result.generatedCode, 574 - error: result.error, 575 - }); 576 - const html = render(component); 577 - 578 - return new Response(html, { 579 - headers: { "content-type": "text/html; charset=utf-8" }, 580 - }); 581 - } catch (error) { 582 - console.error("Codegen error:", error); 583 - const component = await CodegenResult({ 584 - success: false, 585 - error: `Error: ${error instanceof Error ? error.message : String(error)}`, 586 - }); 587 - const html = render(component); 588 - 589 - return new Response(html, { 590 - headers: { "content-type": "text/html; charset=utf-8" }, 591 - }); 592 - } 593 - } 594 - 595 - async function handleUpdateProfile(req: Request): Promise<Response> { 596 - const context = await withAuth(req); 597 - const authResponse = requireAuth(context); 598 - if (authResponse) return authResponse; 599 - 600 - try { 601 - const formData = await req.formData(); 602 - const displayName = formData.get("displayName") as string; 603 - const description = formData.get("description") as string; 604 - const avatarFile = formData.get("avatar") as File; 605 - 606 - // Build profile record 607 - const profileData: Partial<SocialSlicesActorProfile> = { 608 - displayName: displayName?.trim() || undefined, 609 - description: description?.trim() || undefined, 610 - createdAt: new Date().toISOString(), 611 - }; 612 - 613 - // Handle avatar if provided 614 - if (avatarFile && avatarFile.size > 0) { 615 - try { 616 - // Upload blob with binary data directly 617 - const arrayBuffer = await avatarFile.arrayBuffer(); 618 - 619 - // Upload blob via the generated client 620 - const blobResult = await atprotoClient.uploadBlob({ 621 - data: arrayBuffer, 622 - mimeType: avatarFile.type, 623 - }); 624 - 625 - // Add blob reference to profile data 626 - profileData.avatar = blobResult.blob; 627 - } catch (avatarError) { 628 - console.error("Failed to upload avatar:", avatarError); 629 - // Continue without avatar - don't fail the entire profile update 630 - } 631 - } 632 - 633 - // Check if profile already exists 634 - try { 635 - if (!context.currentUser.sub) { 636 - throw new Error("User DID (sub) is required for profile operations"); 637 - } 638 - 639 - const existingProfile = 640 - await atprotoClient.social.slices.actor.profile.getRecord({ 641 - uri: buildAtUri({ 642 - did: context.currentUser.sub, 643 - collection: "social.slices.actor.profile", 644 - rkey: "self", 645 - }), 646 - }); 647 - 648 - if (existingProfile) { 649 - // Update existing profile 650 - await atprotoClient.social.slices.actor.profile.updateRecord("self", { 651 - ...profileData, 652 - createdAt: existingProfile.value.createdAt, // Keep original creation time 653 - }); 654 - } else { 655 - // Create new profile 656 - await atprotoClient.social.slices.actor.profile.createRecord( 657 - profileData, 658 - true 659 - ); 660 - } 661 - 662 - const html = render( 663 - <SettingsResult 664 - type="success" 665 - message="Profile updated successfully!" 666 - showRefresh 667 - /> 668 - ); 669 - return new Response(html, { 670 - status: 200, 671 - headers: { "content-type": "text/html" }, 672 - }); 673 - } catch (profileError) { 674 - console.error("Profile update error:", profileError); 675 - const errorMessage = 676 - profileError instanceof Error 677 - ? profileError.message 678 - : String(profileError); 679 - 680 - const html = render( 681 - <SettingsResult 682 - type="error" 683 - message={`Failed to update profile: ${errorMessage}`} 684 - /> 685 - ); 686 - return new Response(html, { 687 - status: 500, 688 - headers: { "content-type": "text/html" }, 689 - }); 690 - } 691 - } catch (error) { 692 - console.error("Form processing error:", error); 693 - const html = render( 694 - <SettingsResult type="error" message="Failed to process form data" /> 695 - ); 696 - return new Response(html, { 697 - status: 400, 698 - headers: { "content-type": "text/html" }, 699 - }); 700 - } 701 - } 702 - 703 - async function handleJobHistory( 704 - req: Request, 705 - params?: URLPatternResult 706 - ): Promise<Response> { 707 - const context = await withAuth(req); 708 - const authResponse = requireAuth(context); 709 - if (authResponse) return authResponse; 710 - 711 - const sliceId = params?.pathname.groups.id; 712 - if (!sliceId) { 713 - const html = render( 714 - <div className="text-red-700 text-sm">❌ Invalid slice ID</div> 715 - ); 716 - return new Response(html, { 717 - status: 400, 718 - headers: { "content-type": "text/html" }, 719 - }); 720 - } 721 - 722 - try { 723 - // Construct the slice URI 724 - const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 725 - 726 - // Use the slice-specific client 727 - const sliceClient = getSliceClient(context, sliceId); 728 - 729 - // Get job history 730 - const result = await sliceClient.social.slices.slice.getJobHistory({ 731 - userDid: context.currentUser.sub!, 732 - sliceUri: sliceUri, 733 - limit: 10, 734 - }); 735 - 736 - const jobs = result || []; 737 - const html = render(<JobHistory jobs={jobs} sliceId={sliceId} />); 738 - 739 - return new Response(html, { 740 - status: 200, 741 - headers: { "content-type": "text/html" }, 742 - }); 743 - } catch (error) { 744 - console.error("Failed to get job history:", error); 745 - const html = render(<JobHistory jobs={[]} sliceId={sliceId} />); 746 - return new Response(html, { 747 - status: 200, 748 - headers: { "content-type": "text/html" }, 749 - }); 750 - } 751 - } 752 - 753 - async function handleSyncJobLogs( 754 - req: Request, 755 - params?: URLPatternResult 756 - ): Promise<Response> { 757 - const context = await withAuth(req); 758 - const authResponse = requireAuth(context); 759 - if (authResponse) return authResponse; 760 - 761 - const sliceId = params?.pathname.groups.id; 762 - const jobId = params?.pathname.groups.jobId; 763 - 764 - if (!sliceId || !jobId) { 765 - const html = render( 766 - <div className="p-8 text-center text-red-600"> 767 - ❌ Invalid slice ID or job ID 768 - </div> 769 - ); 770 - return new Response(html, { 771 - status: 400, 772 - headers: { "content-type": "text/html" }, 773 - }); 774 - } 775 - 776 - try { 777 - // Use the slice-specific client 778 - const sliceClient = getSliceClient(context, sliceId); 779 - 780 - // Get job logs 781 - const result = await sliceClient.social.slices.slice.getJobLogs({ 782 - jobId: jobId, 783 - limit: 1000, 784 - }); 785 - 786 - const logs = result?.logs || []; 787 - const html = render(<SyncJobLogs logs={logs} jobId={jobId} />); 788 - 789 - return new Response(html, { 790 - status: 200, 791 - headers: { "content-type": "text/html" }, 792 - }); 793 - } catch (error) { 794 - console.error("Failed to get sync job logs:", error); 795 - const errorMessage = error instanceof Error ? error.message : String(error); 796 - const html = render( 797 - <div className="p-8 text-center text-red-600"> 798 - ❌ Error loading logs: {errorMessage} 799 - </div> 800 - ); 801 - return new Response(html, { 802 - status: 500, 803 - headers: { "content-type": "text/html" }, 804 - }); 805 - } 806 - } 807 - 808 - async function handleSliceSync( 809 - req: Request, 810 - params?: URLPatternResult 811 - ): Promise<Response> { 812 - const context = await withAuth(req); 813 - const authResponse = requireAuth(context); 814 - if (authResponse) return authResponse; 815 - 816 - const sliceId = params?.pathname.groups.id; 817 - if (!sliceId) { 818 - const html = render( 819 - <SyncResult success={false} error="Invalid slice ID" /> 820 - ); 821 - return new Response(html, { 822 - status: 400, 823 - headers: { "content-type": "text/html" }, 824 - }); 825 - } 826 - 827 - try { 828 - const formData = await req.formData(); 829 - const collectionsText = formData.get("collections") as string; 830 - const externalCollectionsText = formData.get( 831 - "external_collections" 832 - ) as string; 833 - const reposText = formData.get("repos") as string; 834 - 835 - // Parse primary collections from textarea (newline or comma separated) 836 - const collections: string[] = []; 837 - if (collectionsText) { 838 - collectionsText.split(/[\n,]/).forEach((item) => { 839 - const trimmed = item.trim(); 840 - if (trimmed) collections.push(trimmed); 841 - }); 842 - } 843 - 844 - // Parse external collections from textarea (newline or comma separated) 845 - const externalCollections: string[] = []; 846 - if (externalCollectionsText) { 847 - externalCollectionsText.split(/[\n,]/).forEach((item) => { 848 - const trimmed = item.trim(); 849 - if (trimmed) externalCollections.push(trimmed); 850 - }); 851 - } 852 - 853 - if (collections.length === 0 && externalCollections.length === 0) { 854 - const html = render( 855 - <SyncResult 856 - success={false} 857 - error="Please specify at least one collection (primary or external) to sync" 858 - /> 859 - ); 860 - return new Response(html, { 861 - status: 400, 862 - headers: { "content-type": "text/html" }, 863 - }); 864 - } 865 - 866 - // Parse repos if provided 867 - const repos: string[] = []; 868 - if (reposText) { 869 - reposText.split(/[\n,]/).forEach((item) => { 870 - const trimmed = item.trim(); 871 - if (trimmed) repos.push(trimmed); 872 - }); 873 - } 874 - 875 - // Start sync job using the new job queue 876 - // Use slice-specific client to ensure consistent slice URI 877 - const sliceClient = getSliceClient(context, sliceId); 878 - const syncJobResponse = await sliceClient.social.slices.slice.startSync({ 879 - collections: collections.length > 0 ? collections : undefined, 880 - externalCollections: 881 - externalCollections.length > 0 ? externalCollections : undefined, 882 - repos: repos.length > 0 ? repos : undefined, 883 - }); 884 - 885 - const html = render( 886 - <SyncResult 887 - success={syncJobResponse.success} 888 - message={ 889 - syncJobResponse.success 890 - ? `Sync job started successfully. Job ID: ${syncJobResponse.jobId}` 891 - : syncJobResponse.message 892 - } 893 - jobId={syncJobResponse.jobId} 894 - collectionsCount={collections.length + externalCollections.length} 895 - error={syncJobResponse.success ? undefined : syncJobResponse.message} 896 - /> 897 - ); 898 - 899 - return new Response(html, { 900 - status: 200, 901 - headers: { "content-type": "text/html" }, 902 - }); 903 - } catch (error) { 904 - const html = render( 905 - <SyncResult 906 - success={false} 907 - error={`Error: ${ 908 - error instanceof Error ? error.message : String(error) 909 - }`} 910 - /> 911 - ); 912 - return new Response(html, { 913 - status: 500, 914 - headers: { "content-type": "text/html" }, 915 - }); 916 - } 917 - } 918 - 919 - async function handleOAuthClientNew(req: Request): Promise<Response> { 920 - const context = await withAuth(req); 921 - const authResponse = requireAuth(context); 922 - if (authResponse) return authResponse; 923 - 924 - const url = new URL(req.url); 925 - const sliceId = url.pathname.split("/")[3]; 926 - 927 - try { 928 - // Build the slice URI 929 - const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 930 - 931 - const html = render( 932 - <OAuthClientModal sliceId={sliceId} sliceUri={sliceUri} mode="new" /> 933 - ); 934 - 935 - return new Response(html, { 936 - status: 200, 937 - headers: { "content-type": "text/html" }, 938 - }); 939 - } catch (error) { 940 - console.error("Error:", error); 941 - return new Response("Failed to load modal", { status: 500 }); 942 - } 943 - } 944 - 945 - async function handleOAuthClientRegister(req: Request): Promise<Response> { 946 - const context = await withAuth(req); 947 - const authResponse = requireAuth(context); 948 - if (authResponse) return authResponse; 949 - 950 - const url = new URL(req.url); 951 - const sliceId = url.pathname.split("/")[3]; 952 - 953 - try { 954 - const formData = await req.formData(); 955 - const sliceUri = formData.get("sliceUri") as string; 956 - const clientName = formData.get("clientName") as string; 957 - const redirectUris = (formData.get("redirectUris") as string) 958 - .split("\n") 959 - .map((uri) => uri.trim()) 960 - .filter((uri) => uri.length > 0); 961 - const scope = (formData.get("scope") as string) || undefined; 962 - const clientUri = (formData.get("clientUri") as string) || undefined; 963 - const logoUri = (formData.get("logoUri") as string) || undefined; 964 - const tosUri = (formData.get("tosUri") as string) || undefined; 965 - const policyUri = (formData.get("policyUri") as string) || undefined; 966 - 967 - // Create OAuth client via backend API 968 - const sliceClient = getSliceClient(context, sliceId); 969 - const clientDetails = 970 - await sliceClient.social.slices.slice.createOAuthClient({ 971 - clientName, 972 - redirectUris, 973 - grantTypes: ["authorization_code"], 974 - responseTypes: ["code"], 975 - ...(scope && { scope }), 976 - ...(clientUri && { clientUri }), 977 - ...(logoUri && { logoUri }), 978 - ...(tosUri && { tosUri }), 979 - ...(policyUri && { policyUri }), 980 - }); 981 - 982 - // Return success response using JSX component 983 - const html = render( 984 - <OAuthRegistrationResult 985 - success 986 - sliceId={sliceId} 987 - clientId={clientDetails.clientId} 988 - /> 989 - ); 990 - 991 - return new Response(html, { 992 - status: 200, 993 - headers: { "content-type": "text/html" }, 994 - }); 995 - } catch (error) { 996 - console.error("Error registering OAuth client:", error); 997 - const html = render( 998 - <OAuthRegistrationResult 999 - success={false} 1000 - sliceId={sliceId} 1001 - error={error instanceof Error ? error.message : String(error)} 1002 - /> 1003 - ); 1004 - 1005 - return new Response(html, { 1006 - status: 200, 1007 - headers: { "content-type": "text/html" }, 1008 - }); 1009 - } 1010 - } 1011 - 1012 - async function handleOAuthClientDelete(req: Request): Promise<Response> { 1013 - const context = await withAuth(req); 1014 - const authResponse = requireAuth(context); 1015 - if (authResponse) return authResponse; 1016 - 1017 - const url = new URL(req.url); 1018 - const pathParts = url.pathname.split("/"); 1019 - const sliceId = pathParts[3]; 1020 - const clientId = decodeURIComponent(pathParts[pathParts.length - 1]); 1021 - 1022 - try { 1023 - const sliceClient = getSliceClient(context, sliceId); 1024 - 1025 - // Delete the OAuth client via backend API 1026 - await sliceClient.social.slices.slice.deleteOAuthClient(clientId); 1027 - 1028 - // Return empty response to remove the row 1029 - const html = render(<OAuthDeleteResult success />); 1030 - return new Response(html || "", { 1031 - status: 200, 1032 - headers: { "content-type": "text/html" }, 1033 - }); 1034 - } catch (error) { 1035 - console.error("Error deleting OAuth client:", error); 1036 - const html = render( 1037 - <OAuthDeleteResult 1038 - success={false} 1039 - error={error instanceof Error ? error.message : String(error)} 1040 - /> 1041 - ); 1042 - return new Response(html || "", { 1043 - status: 200, 1044 - headers: { "content-type": "text/html" }, 1045 - }); 1046 - } 1047 - } 1048 - 1049 - async function handleOAuthClientView(req: Request): Promise<Response> { 1050 - const context = await withAuth(req); 1051 - const authResponse = requireAuth(context); 1052 - if (authResponse) return authResponse; 1053 - 1054 - const url = new URL(req.url); 1055 - const pathParts = url.pathname.split("/"); 1056 - const sliceId = pathParts[3]; 1057 - const clientId = decodeURIComponent(pathParts[5]); 1058 - 1059 - try { 1060 - const sliceClient = getSliceClient(context, sliceId); 1061 - 1062 - // Get OAuth clients to find the specific one 1063 - const clients = await sliceClient.social.slices.slice.getOAuthClients(); 1064 - const client = clients.clients.find((c) => c.clientId === clientId); 1065 - 1066 - if (!client) { 1067 - const html = render( 1068 - <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> 1069 - <div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full"> 1070 - <h2 className="text-xl font-semibold text-gray-800 mb-4"> 1071 - OAuth Client Not Found 1072 - </h2> 1073 - <p className="text-gray-600 mb-4"> 1074 - The requested OAuth client could not be found. 1075 - </p> 1076 - <button 1077 - type="button" 1078 - _="on click set #modal-container's innerHTML to ''" 1079 - className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition" 1080 - > 1081 - Close 1082 - </button> 1083 - </div> 1084 - </div> 1085 - ); 1086 - return new Response(html || "", { 1087 - status: 404, 1088 - headers: { "content-type": "text/html" }, 1089 - }); 1090 - } 1091 - 1092 - const sliceUri = `at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/${sliceId}`; 1093 - const html = render( 1094 - <OAuthClientModal 1095 - sliceId={sliceId} 1096 - sliceUri={sliceUri} 1097 - mode="view" 1098 - clientData={client} 1099 - /> 1100 - ); 1101 - 1102 - return new Response(html || "", { 1103 - status: 200, 1104 - headers: { "content-type": "text/html" }, 1105 - }); 1106 - } catch (error) { 1107 - console.error("Error viewing OAuth client:", error); 1108 - const html = render( 1109 - <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> 1110 - <div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full"> 1111 - <h2 className="text-xl font-semibold text-gray-800 mb-4">Error</h2> 1112 - <p className="text-gray-600 mb-4"> 1113 - Failed to load OAuth client details:{" "} 1114 - {error instanceof Error ? error.message : String(error)} 1115 - </p> 1116 - <button 1117 - type="button" 1118 - _="on click set #modal-container's innerHTML to ''" 1119 - className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition" 1120 - > 1121 - Close 1122 - </button> 1123 - </div> 1124 - </div> 1125 - ); 1126 - return new Response(html || "", { 1127 - status: 500, 1128 - headers: { "content-type": "text/html" }, 1129 - }); 1130 - } 1131 - } 1132 - 1133 - async function handleOAuthClientUpdate(req: Request): Promise<Response> { 1134 - const context = await withAuth(req); 1135 - const authResponse = requireAuth(context); 1136 - if (authResponse) return authResponse; 1137 - 1138 - const url = new URL(req.url); 1139 - const pathParts = url.pathname.split("/"); 1140 - const sliceId = pathParts[3]; 1141 - const clientId = decodeURIComponent(pathParts[5]); 1142 - 1143 - try { 1144 - const formData = await req.formData(); 1145 - const clientName = formData.get("clientName") as string; 1146 - const redirectUrisText = formData.get("redirectUris") as string; 1147 - const scope = formData.get("scope") as string; 1148 - const clientUri = formData.get("clientUri") as string; 1149 - const logoUri = formData.get("logoUri") as string; 1150 - const tosUri = formData.get("tosUri") as string; 1151 - const policyUri = formData.get("policyUri") as string; 1152 - 1153 - // Parse redirect URIs (split by lines and filter empty) 1154 - const redirectUris = redirectUrisText 1155 - .split("\n") 1156 - .map((uri) => uri.trim()) 1157 - .filter((uri) => uri.length > 0); 1158 - 1159 - // Update OAuth client via backend API 1160 - const sliceClient = getSliceClient(context, sliceId); 1161 - const updatedClient = 1162 - await sliceClient.social.slices.slice.updateOAuthClient({ 1163 - clientId, 1164 - clientName: clientName || undefined, 1165 - redirectUris: redirectUris.length > 0 ? redirectUris : undefined, 1166 - scope: scope || undefined, 1167 - clientUri: clientUri || undefined, 1168 - logoUri: logoUri || undefined, 1169 - tosUri: tosUri || undefined, 1170 - policyUri: policyUri || undefined, 1171 - }); 1172 - 1173 - const sliceUri = `at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/${sliceId}`; 1174 - const html = render( 1175 - <OAuthClientModal 1176 - sliceId={sliceId} 1177 - sliceUri={sliceUri} 1178 - mode="view" 1179 - clientData={updatedClient} 1180 - /> 1181 - ); 1182 - return new Response(html, { 1183 - status: 200, 1184 - headers: { "content-type": "text/html" }, 1185 - }); 1186 - } catch (error) { 1187 - console.error("Error updating OAuth client:", error); 1188 - const html = render( 1189 - <OAuthDeleteResult 1190 - success={false} 1191 - error={error instanceof Error ? error.message : String(error)} 1192 - /> 1193 - ); 1194 - return new Response(html, { 1195 - status: 500, 1196 - headers: { "content-type": "text/html" }, 1197 - }); 1198 - } 1199 - } 1200 - 1201 - async function handleJetstreamLogs( 1202 - req: Request, 1203 - params?: URLPatternResult 1204 - ): Promise<Response> { 1205 - const context = await withAuth(req); 1206 - const authResponse = requireAuth(context); 1207 - if (authResponse) return authResponse; 1208 - 1209 - const sliceId = params?.pathname.groups.id; 1210 - if (!sliceId) { 1211 - const html = render( 1212 - <div className="p-8 text-center text-red-600">❌ Invalid slice ID</div> 1213 - ); 1214 - return new Response(html, { 1215 - status: 400, 1216 - headers: { "content-type": "text/html" }, 1217 - }); 1218 - } 1219 - 1220 - try { 1221 - // Use the slice-specific client 1222 - const sliceClient = getSliceClient(context, sliceId); 1223 - 1224 - // Get Jetstream logs 1225 - const result = await sliceClient.social.slices.slice.getJetstreamLogs({ 1226 - limit: 100, 1227 - }); 1228 - 1229 - const logs = result?.logs || []; 1230 - 1231 - // Sort logs in descending order (newest first) 1232 - const sortedLogs = logs.sort( 1233 - (a, b) => 1234 - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 1235 - ); 1236 - 1237 - // Render the log content 1238 - const html = render(<JetstreamLogs logs={sortedLogs} />); 1239 - 1240 - return new Response(html, { 1241 - status: 200, 1242 - headers: { "content-type": "text/html" }, 1243 - }); 1244 - } catch (error) { 1245 - console.error("Failed to get Jetstream logs:", error); 1246 - const errorMessage = error instanceof Error ? error.message : String(error); 1247 - const html = render( 1248 - <Layout title="Error"> 1249 - <div className="max-w-6xl mx-auto"> 1250 - <div className="flex items-center gap-4 mb-6"> 1251 - <a 1252 - href={`/slices/${sliceId}`} 1253 - className="text-blue-600 hover:text-blue-800" 1254 - > 1255 - ← Back to Slice 1256 - </a> 1257 - <h1 className="text-2xl font-semibold text-gray-900"> 1258 - ✈️ Jetstream Logs 1259 - </h1> 1260 - </div> 1261 - <div className="p-8 text-center text-red-600"> 1262 - ❌ Error loading Jetstream logs: {errorMessage} 1263 - </div> 1264 - </div> 1265 - </Layout> 1266 - ); 1267 - return new Response(html, { 1268 - status: 500, 1269 - headers: { "content-type": "text/html" }, 1270 - }); 1271 - } 1272 - } 1273 - 1274 - async function handleJetstreamStatus( 1275 - req: Request, 1276 - _params?: URLPatternResult 1277 - ): Promise<Response> { 1278 - try { 1279 - // Extract parameters from query 1280 - const url = new URL(req.url); 1281 - const sliceId = url.searchParams.get("sliceId"); 1282 - const isCompact = url.searchParams.get("compact") === "true"; 1283 - 1284 - // Fetch jetstream status using the atproto client 1285 - const data = await atprotoClient.social.slices.slice.getJetstreamStatus(); 1286 - 1287 - // Render compact version for logs page 1288 - if (isCompact) { 1289 - const html = render( 1290 - <div className="inline-flex items-center gap-2 text-xs"> 1291 - {data.connected ? ( 1292 - <> 1293 - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> 1294 - <span className="text-green-700">Jetstream Connected</span> 1295 - </> 1296 - ) : ( 1297 - <> 1298 - <div className="w-2 h-2 bg-red-500 rounded-full"></div> 1299 - <span className="text-red-700">Jetstream Offline</span> 1300 - </> 1301 - )} 1302 - </div> 1303 - ); 1304 - 1305 - return new Response(html, { 1306 - status: 200, 1307 - headers: { "content-type": "text/html" }, 1308 - }); 1309 - } 1310 - 1311 - // Render full version for main page 1312 - const html = render( 1313 - <JetstreamStatus 1314 - connected={data.connected} 1315 - status={data.status} 1316 - error={data.error} 1317 - sliceId={sliceId || undefined} 1318 - /> 1319 - ); 1320 - 1321 - return new Response(html, { 1322 - status: 200, 1323 - headers: { "content-type": "text/html" }, 1324 - }); 1325 - } catch (error) { 1326 - // Extract parameters for error case too 1327 - const url = new URL(req.url); 1328 - const sliceId = url.searchParams.get("sliceId"); 1329 - const isCompact = url.searchParams.get("compact") === "true"; 1330 - 1331 - // Render compact error version 1332 - if (isCompact) { 1333 - const html = render( 1334 - <div className="inline-flex items-center gap-2 text-xs"> 1335 - <div className="w-2 h-2 bg-red-500 rounded-full"></div> 1336 - <span className="text-red-700">Jetstream Offline</span> 1337 - </div> 1338 - ); 1339 - 1340 - return new Response(html, { 1341 - status: 200, 1342 - headers: { "content-type": "text/html" }, 1343 - }); 1344 - } 1345 - 1346 - // Fallback to disconnected state on error for full version 1347 - const html = render( 1348 - <JetstreamStatus 1349 - connected={false} 1350 - status="Connection error" 1351 - error={error instanceof Error ? error.message : "Unknown error"} 1352 - sliceId={sliceId || undefined} 1353 - /> 1354 - ); 1355 - 1356 - return new Response(html, { 1357 - status: 200, 1358 - headers: { "content-type": "text/html" }, 1359 - }); 1360 - } 1361 - } 1362 - 1363 - export const sliceRoutes: Route[] = [ 1364 - { 1365 - method: "POST", 1366 - pattern: new URLPattern({ pathname: "/slices" }), 1367 - handler: handleCreateSlice, 1368 - }, 1369 - { 1370 - method: "PUT", 1371 - pattern: new URLPattern({ pathname: "/api/slices/:id/settings" }), 1372 - handler: handleUpdateSliceSettings, 1373 - }, 1374 - { 1375 - method: "DELETE", 1376 - pattern: new URLPattern({ pathname: "/api/slices/:id" }), 1377 - handler: handleDeleteSlice, 1378 - }, 1379 - { 1380 - method: "POST", 1381 - pattern: new URLPattern({ pathname: "/api/lexicons" }), 1382 - handler: handleCreateLexicon, 1383 - }, 1384 - { 1385 - method: "GET", 1386 - pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/list" }), 1387 - handler: handleListLexicons, 1388 - }, 1389 - { 1390 - method: "GET", 1391 - pattern: new URLPattern({ 1392 - pathname: "/api/slices/:id/lexicons/:rkey/view", 1393 - }), 1394 - handler: handleViewLexicon, 1395 - }, 1396 - { 1397 - method: "DELETE", 1398 - pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/:rkey" }), 1399 - handler: handleDeleteLexicon, 1400 - }, 1401 - { 1402 - method: "POST", 1403 - pattern: new URLPattern({ pathname: "/api/slices/:id/codegen" }), 1404 - handler: handleSliceCodegen, 1405 - }, 1406 - { 1407 - method: "POST", 1408 - pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons" }), 1409 - handler: handleCreateLexicon, 1410 - }, 1411 - { 1412 - method: "PUT", 1413 - pattern: new URLPattern({ pathname: "/api/profile" }), 1414 - handler: handleUpdateProfile, 1415 - }, 1416 - { 1417 - method: "POST", 1418 - pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }), 1419 - handler: handleSliceSync, 1420 - }, 1421 - { 1422 - method: "GET", 1423 - pattern: new URLPattern({ pathname: "/api/slices/:id/job-history" }), 1424 - handler: handleJobHistory, 1425 - }, 1426 - { 1427 - method: "GET", 1428 - pattern: new URLPattern({ pathname: "/api/slices/:id/sync/logs/:jobId" }), 1429 - handler: handleSyncJobLogs, 1430 - }, 1431 - { 1432 - method: "GET", 1433 - pattern: new URLPattern({ pathname: "/api/jetstream/status" }), 1434 - handler: handleJetstreamStatus, 1435 - }, 1436 - { 1437 - method: "GET", 1438 - pattern: new URLPattern({ pathname: "/api/slices/:id/jetstream/logs" }), 1439 - handler: handleJetstreamLogs, 1440 - }, 1441 - { 1442 - method: "GET", 1443 - pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/new" }), 1444 - handler: handleOAuthClientNew, 1445 - }, 1446 - { 1447 - method: "POST", 1448 - pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/register" }), 1449 - handler: handleOAuthClientRegister, 1450 - }, 1451 - { 1452 - method: "GET", 1453 - pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/view" }), 1454 - handler: handleOAuthClientView, 1455 - }, 1456 - { 1457 - method: "POST", 1458 - pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/update" }), 1459 - handler: handleOAuthClientUpdate, 1460 - }, 1461 - { 1462 - method: "DELETE", 1463 - pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri" }), 1464 - handler: handleOAuthClientDelete, 1465 - }, 1466 - ];
+70
frontend/src/shared/fragments/Button.tsx
··· 1 + import type { JSX } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + type ButtonVariant = 5 + | "primary" // blue 6 + | "secondary" // gray 7 + | "danger" // red 8 + | "success" // green 9 + | "warning" // orange 10 + | "purple" // purple 11 + | "indigo" // indigo 12 + | "ghost"; // transparent with hover 13 + 14 + type ButtonSize = "sm" | "md" | "lg"; 15 + 16 + export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { 17 + variant?: ButtonVariant; 18 + size?: ButtonSize; 19 + children: JSX.Element | JSX.Element[] | string; 20 + href?: string; // If provided, renders as a link styled like a button 21 + } 22 + 23 + const variantClasses = { 24 + primary: "bg-blue-500 hover:bg-blue-600 text-white", 25 + secondary: "bg-gray-500 hover:bg-gray-600 text-white", 26 + danger: "bg-red-600 hover:bg-red-700 text-white", 27 + success: "bg-green-500 hover:bg-green-600 text-white", 28 + warning: "bg-orange-500 hover:bg-orange-600 text-white", 29 + purple: "bg-purple-500 hover:bg-purple-600 text-white", 30 + indigo: "bg-indigo-500 hover:bg-indigo-600 text-white", 31 + ghost: "bg-transparent hover:bg-gray-100 text-current", 32 + }; 33 + 34 + const sizeClasses = { 35 + sm: "px-3 py-1 text-sm", 36 + md: "px-4 py-2", 37 + lg: "px-6 py-2 font-medium", 38 + }; 39 + 40 + export function Button(props: ButtonProps): JSX.Element { 41 + const { 42 + variant = "primary", 43 + size = "md", 44 + children, 45 + href, 46 + class: classProp, 47 + ...rest 48 + } = props; 49 + 50 + const className = cn( 51 + "rounded transition-colors inline-flex items-center", 52 + variantClasses[variant], 53 + sizeClasses[size], 54 + classProp 55 + ); 56 + 57 + if (href) { 58 + return ( 59 + <a href={href} class={className}> 60 + {children} 61 + </a> 62 + ); 63 + } 64 + 65 + return ( 66 + <button class={className} {...rest}> 67 + {children} 68 + </button> 69 + ); 70 + }
+19
frontend/src/shared/fragments/FlashMessage.tsx
··· 1 + interface FlashMessageProps { 2 + type: "success" | "error"; 3 + message: string; 4 + className?: string; 5 + } 6 + 7 + export function FlashMessage({ type, message, className = "" }: FlashMessageProps) { 8 + const baseClasses = "px-4 py-3 rounded mb-4 border"; 9 + const typeClasses = type === "success" 10 + ? "bg-green-100 border-green-400 text-green-700" 11 + : "bg-red-100 border-red-400 text-red-700"; 12 + const icon = type === "success" ? "✅" : "❌"; 13 + 14 + return ( 15 + <div className={`${baseClasses} ${typeClasses} ${className}`}> 16 + {icon} {message} 17 + </div> 18 + ); 19 + }
+30
frontend/src/shared/fragments/Input.tsx
··· 1 + import type { JSX } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> { 5 + label?: string; 6 + error?: string; 7 + } 8 + 9 + export function Input(props: InputProps): JSX.Element { 10 + const { class: classProp, label, error, ...rest } = props; 11 + const className = cn( 12 + "block w-full border border-gray-300 rounded-md px-3 py-2", 13 + error ? "border-red-300 focus:border-red-500 focus:ring-red-500" : "focus:border-blue-500 focus:ring-blue-500", 14 + classProp, 15 + ); 16 + 17 + return ( 18 + <div> 19 + {label && ( 20 + <label className="block text-sm font-medium text-gray-700 mb-2"> 21 + {label} 22 + </label> 23 + )} 24 + <input class={className} {...rest} /> 25 + {error && ( 26 + <p className="mt-1 text-sm text-red-600">{error}</p> 27 + )} 28 + </div> 29 + ); 30 + }
+120
frontend/src/shared/fragments/Layout.tsx
··· 1 + import { JSX } from "preact"; 2 + import type { AuthenticatedUser } from "../../routes/middleware.ts"; 3 + 4 + interface LayoutProps { 5 + title?: string; 6 + description?: string; 7 + children: JSX.Element | JSX.Element[]; 8 + currentUser?: AuthenticatedUser; 9 + showNavigation?: boolean; 10 + } 11 + 12 + export function Layout({ 13 + title = "Slices", 14 + description = "AT Protocol data management platform", 15 + children, 16 + currentUser, 17 + showNavigation = true, 18 + }: LayoutProps) { 19 + return ( 20 + <html lang="en"> 21 + <head> 22 + <meta charSet="UTF-8" /> 23 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 24 + <title>{title}</title> 25 + <meta name="description" content={description} /> 26 + 27 + {/* Open Graph / Facebook */} 28 + <meta property="og:type" content="website" /> 29 + <meta property="og:title" content={title} /> 30 + <meta property="og:description" content={description} /> 31 + 32 + {/* Twitter */} 33 + <meta property="twitter:card" content="summary_large_image" /> 34 + <meta property="twitter:title" content={title} /> 35 + <meta property="twitter:description" content={description} /> 36 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 37 + <script src="https://unpkg.com/hyperscript.org@0.9.12"></script> 38 + <script src="https://cdn.tailwindcss.com/3.4.1"></script> 39 + <script src="https://unpkg.com/lucide@latest"></script> 40 + <style 41 + dangerouslySetInnerHTML={{ 42 + __html: ` 43 + 44 + .htmx-indicator { 45 + display: none; 46 + } 47 + 48 + .htmx-request .htmx-indicator { 49 + display: inline; 50 + } 51 + 52 + .htmx-request .default-text { 53 + display: none; 54 + } 55 + `, 56 + }} 57 + /> 58 + </head> 59 + <body className={showNavigation ? "bg-gray-100 min-h-screen" : "min-h-screen"}> 60 + {showNavigation && ( 61 + <nav className="bg-white text-gray-800 py-4 border-b border-gray-200"> 62 + <div className="max-w-5xl mx-auto px-4 flex justify-between items-center"> 63 + <a href="/" className="text-xl font-bold hover:text-blue-600"> 64 + Slices 65 + </a> 66 + <div className="flex items-center space-x-4"> 67 + {currentUser?.isAuthenticated ? ( 68 + <div className="flex items-center space-x-3"> 69 + {currentUser.avatar && ( 70 + <img 71 + src={currentUser.avatar} 72 + alt="Profile avatar" 73 + className="w-6 h-6 rounded-full" 74 + /> 75 + )} 76 + <span className="text-sm text-gray-600"> 77 + {currentUser.handle 78 + ? `@${currentUser.handle}` 79 + : "Authenticated User"} 80 + </span> 81 + <a 82 + href="/settings" 83 + className="text-sm text-gray-600 hover:text-gray-800" 84 + > 85 + Settings 86 + </a> 87 + <form method="post" action="/logout" className="inline"> 88 + <button 89 + type="submit" 90 + className="text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded" 91 + > 92 + Logout 93 + </button> 94 + </form> 95 + </div> 96 + ) : ( 97 + <a 98 + href="/login" 99 + className="text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded" 100 + > 101 + Login 102 + </a> 103 + )} 104 + </div> 105 + </div> 106 + </nav> 107 + )} 108 + {showNavigation ? ( 109 + <main className="max-w-5xl mx-auto mt-8 px-4 pb-16 min-h-[calc(100vh-200px)]"> 110 + {children} 111 + </main> 112 + ) : ( 113 + <main className="w-full"> 114 + {children} 115 + </main> 116 + )} 117 + </body> 118 + </html> 119 + ); 120 + }
+22
frontend/src/shared/fragments/LogLevelBadge.tsx
··· 1 + interface LogLevelBadgeProps { 2 + level: string; 3 + } 4 + 5 + export function LogLevelBadge({ level }: LogLevelBadgeProps) { 6 + 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", 11 + }; 12 + 13 + return ( 14 + <span 15 + className={`px-2 py-1 rounded text-xs font-medium ${ 16 + colors[level] || colors.debug 17 + }`} 18 + > 19 + {level.toUpperCase()} 20 + </span> 21 + ); 22 + }
+32
frontend/src/shared/fragments/Select.tsx
··· 1 + import type { JSX } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + export interface SelectProps extends JSX.SelectHTMLAttributes<HTMLSelectElement> { 5 + label?: string; 6 + error?: string; 7 + } 8 + 9 + export function Select(props: SelectProps): JSX.Element { 10 + const { class: classProp, label, error, children, ...rest } = props; 11 + const className = cn( 12 + "block w-full border border-gray-300 rounded-md px-3 py-2", 13 + error ? "border-red-300 focus:border-red-500 focus:ring-red-500" : "focus:border-blue-500 focus:ring-blue-500", 14 + classProp, 15 + ); 16 + 17 + return ( 18 + <div> 19 + {label && ( 20 + <label className="block text-sm font-medium text-gray-700 mb-2"> 21 + {label} 22 + </label> 23 + )} 24 + <select class={className} {...rest}> 25 + {children} 26 + </select> 27 + {error && ( 28 + <p className="mt-1 text-sm text-red-600">{error}</p> 29 + )} 30 + </div> 31 + ); 32 + }
+30
frontend/src/shared/fragments/Textarea.tsx
··· 1 + import type { JSX } from "preact"; 2 + import { cn } from "../../utils/cn.ts"; 3 + 4 + export interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> { 5 + label?: string; 6 + error?: string; 7 + } 8 + 9 + export function Textarea(props: TextareaProps): JSX.Element { 10 + const { class: classProp, label, error, ...rest } = props; 11 + const className = cn( 12 + "block w-full border border-gray-300 rounded-md px-3 py-2", 13 + error ? "border-red-300 focus:border-red-500 focus:ring-red-500" : "focus:border-blue-500 focus:ring-blue-500", 14 + classProp, 15 + ); 16 + 17 + return ( 18 + <div> 19 + {label && ( 20 + <label className="block text-sm font-medium text-gray-700 mb-2"> 21 + {label} 22 + </label> 23 + )} 24 + <textarea class={className} {...rest} /> 25 + {error && ( 26 + <p className="mt-1 text-sm text-red-600">{error}</p> 27 + )} 28 + </div> 29 + ); 30 + }
+6
frontend/src/utils/cn.ts
··· 1 + import { type ClassValue, clsx } from "clsx"; 2 + import { twMerge } from "tailwind-merge"; 3 + 4 + export function cn(...inputs: ClassValue[]): string { 5 + return twMerge(clsx(inputs)); 6 + }
+14
frontend/src/utils/htmx.ts
··· 1 + /** 2 + * Creates an HTMX redirect response 3 + * @param url - The URL to redirect to 4 + * @param status - HTTP status code (default: 200) 5 + * @returns Response with HX-Redirect header 6 + */ 7 + export function hxRedirect(url: string, status: number = 200): Response { 8 + return new Response("", { 9 + status, 10 + headers: { 11 + "HX-Redirect": url, 12 + }, 13 + }); 14 + }
+30
frontend/src/utils/render.tsx
··· 1 + import { render } from "preact-render-to-string"; 2 + import { VNode } from "preact"; 3 + 4 + /** 5 + * Renders JSX to an HTML Response with proper headers 6 + * @param jsx - The JSX element to render 7 + * @param options - Optional response configuration 8 + * @returns A Response object with rendered HTML 9 + */ 10 + export function renderHTML( 11 + jsx: VNode, 12 + options?: { 13 + status?: number; 14 + headers?: Record<string, string>; 15 + title?: string; 16 + description?: string; 17 + } 18 + ): Response { 19 + const html = render(jsx); 20 + 21 + const headers: Record<string, string> = { 22 + "content-type": "text/html; charset=utf-8", 23 + ...options?.headers, 24 + }; 25 + 26 + return new Response(`<!DOCTYPE html>${html}`, { 27 + status: options?.status || 200, 28 + headers, 29 + }); 30 + }