+114
-3
frontend/CLAUDE.md
+114
-3
frontend/CLAUDE.md
···
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
+3
-1
frontend/deno.json
···
24
"preact": "npm:preact@^10.27.1",
25
"preact-render-to-string": "npm:preact-render-to-string@^6.5.13",
26
"typed-htmx": "npm:typed-htmx@^0.3.1",
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"
30
},
31
"nodeModulesDir": "auto"
32
}
+12
frontend/deno.lock
+12
frontend/deno.lock
···
20
"npm:@shikijs/engine-oniguruma@^3.7.0": "3.11.0",
21
"npm:@shikijs/types@^3.7.0": "3.11.0",
22
"npm:@types/node@*": "22.15.15",
23
"npm:pg@^8.11.0": "8.16.3",
24
"npm:pg@^8.16.3": "8.16.3",
25
"npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1",
26
"npm:preact@^10.27.1": "10.27.1",
27
"npm:shiki@^3.7.0": "3.11.0",
28
"npm:typed-htmx@*": "0.3.1",
29
"npm:typed-htmx@~0.3.1": "0.3.1"
30
},
···
34
"dependencies": [
35
"npm:@shikijs/core",
36
"npm:@shikijs/engine-oniguruma",
37
"npm:shiki"
38
]
39
},
···
43
"@slices/session@0.1.0": {
44
"integrity": "63a4e35d70dcb2bb58e6117fdccf308f4a86cd9d94cf99412a3de9d35862cabc",
45
"dependencies": [
46
"npm:pg@^8.16.3"
47
]
48
},
···
180
"character-entities-legacy@3.0.0": {
181
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="
182
},
183
"comma-separated-tokens@2.0.3": {
184
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="
185
},
···
381
"character-entities-legacy"
382
]
383
},
384
"trim-lines@3.0.1": {
385
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="
386
},
···
521
"jsr:@slices/session@0.1",
522
"jsr:@std/assert@^1.0.14",
523
"jsr:@std/http@^1.0.20",
524
"npm:preact-render-to-string@^6.5.13",
525
"npm:preact@^10.27.1",
526
"npm:typed-htmx@~0.3.1"
527
]
528
}
···
20
"npm:@shikijs/engine-oniguruma@^3.7.0": "3.11.0",
21
"npm:@shikijs/types@^3.7.0": "3.11.0",
22
"npm:@types/node@*": "22.15.15",
23
+
"npm:clsx@^2.1.1": "2.1.1",
24
"npm:pg@^8.11.0": "8.16.3",
25
"npm:pg@^8.16.3": "8.16.3",
26
"npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1",
27
"npm:preact@^10.27.1": "10.27.1",
28
"npm:shiki@^3.7.0": "3.11.0",
29
+
"npm:tailwind-merge@^2.5.5": "2.6.0",
30
"npm:typed-htmx@*": "0.3.1",
31
"npm:typed-htmx@~0.3.1": "0.3.1"
32
},
···
36
"dependencies": [
37
"npm:@shikijs/core",
38
"npm:@shikijs/engine-oniguruma",
39
+
"npm:@shikijs/types",
40
"npm:shiki"
41
]
42
},
···
46
"@slices/session@0.1.0": {
47
"integrity": "63a4e35d70dcb2bb58e6117fdccf308f4a86cd9d94cf99412a3de9d35862cabc",
48
"dependencies": [
49
+
"jsr:@slices/oauth",
50
"npm:pg@^8.16.3"
51
]
52
},
···
184
"character-entities-legacy@3.0.0": {
185
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="
186
},
187
+
"clsx@2.1.1": {
188
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
189
+
},
190
"comma-separated-tokens@2.0.3": {
191
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="
192
},
···
388
"character-entities-legacy"
389
]
390
},
391
+
"tailwind-merge@2.6.0": {
392
+
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="
393
+
},
394
"trim-lines@3.0.1": {
395
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="
396
},
···
531
"jsr:@slices/session@0.1",
532
"jsr:@std/assert@^1.0.14",
533
"jsr:@std/http@^1.0.20",
534
+
"npm:clsx@^2.1.1",
535
"npm:preact-render-to-string@^6.5.13",
536
"npm:preact@^10.27.1",
537
+
"npm:tailwind-merge@^2.5.5",
538
"npm:typed-htmx@~0.3.1"
539
]
540
}
+11
-15
frontend/src/components/CodegenForm.tsx
frontend/src/features/slices/codegen/templates/fragments/CodegenForm.tsx
+11
-15
frontend/src/components/CodegenForm.tsx
frontend/src/features/slices/codegen/templates/fragments/CodegenForm.tsx
···
1
interface CodegenFormProps {
2
sliceId: string;
3
}
···
19
hx-swap="innerHTML"
20
className="space-y-4"
21
>
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>
33
34
-
<button
35
type="submit"
36
-
className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-md flex items-center justify-center"
37
>
38
<i
39
data-lucide="loader-2"
···
42
></i>
43
<span className="htmx-indicator">Generating Client...</span>
44
<span className="default-text">Generate Client</span>
45
-
</button>
46
</form>
47
48
<div id="generationResult" className="mt-4">
···
50
</div>
51
</div>
52
);
53
-
}
···
1
+
import { Button } from "../../../../../shared/fragments/Button.tsx";
2
+
import { Select } from "../../../../../shared/fragments/Select.tsx";
3
+
4
interface CodegenFormProps {
5
sliceId: string;
6
}
···
22
hx-swap="innerHTML"
23
className="space-y-4"
24
>
25
+
<Select label="Output Format" name="format">
26
+
<option value="typescript">TypeScript</option>
27
+
</Select>
28
29
+
<Button
30
type="submit"
31
+
variant="warning"
32
+
class="flex items-center justify-center"
33
>
34
<i
35
data-lucide="loader-2"
···
38
></i>
39
<span className="htmx-indicator">Generating Client...</span>
40
<span className="default-text">Generate Client</span>
41
+
</Button>
42
</form>
43
44
<div id="generationResult" className="mt-4">
···
46
</div>
47
</div>
48
);
49
+
}
+5
-4
frontend/src/components/CodegenResult.tsx
frontend/src/features/slices/codegen/templates/fragments/CodegenResult.tsx
+5
-4
frontend/src/components/CodegenResult.tsx
frontend/src/features/slices/codegen/templates/fragments/CodegenResult.tsx
···
1
import { codeToHtml } from "jsr:@shikijs/shiki";
2
3
interface CodegenResultProps {
4
success: boolean;
···
23
<h3 className="text-lg font-semibold text-green-800">
24
✅ Generated TypeScript Client
25
</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"
29
_="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
Copy to Clipboard
32
-
</button>
33
</div>
34
<div className="rounded overflow-hidden">
35
<div
···
1
import { codeToHtml } from "jsr:@shikijs/shiki";
2
+
import { Button } from "../../../../../shared/fragments/Button.tsx";
3
4
interface CodegenResultProps {
5
success: boolean;
···
24
<h3 className="text-lg font-semibold text-green-800">
25
✅ Generated TypeScript Client
26
</h3>
27
+
<Button
28
+
variant="success"
29
+
size="sm"
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"
31
>
32
Copy to Clipboard
33
+
</Button>
34
</div>
35
<div className="rounded overflow-hidden">
36
<div
+21
-35
frontend/src/components/CreateSliceDialog.tsx
frontend/src/features/dashboard/templates/fragments/CreateSliceDialog.tsx
+21
-35
frontend/src/components/CreateSliceDialog.tsx
frontend/src/features/dashboard/templates/fragments/CreateSliceDialog.tsx
···
1
interface CreateSliceDialogProps {
2
error?: string;
3
name?: string;
···
54
hx-swap="outerHTML"
55
className="space-y-4"
56
>
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>
74
75
<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
83
type="text"
84
id="domain"
85
name="domain"
86
-
value={domain}
87
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"
89
placeholder="e.g. social.grain"
90
/>
91
<p className="mt-1 text-xs text-gray-500">
···
94
</div>
95
96
<div className="flex justify-end space-x-3 pt-4">
97
-
<button
98
type="button"
99
-
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-md"
100
_="on click remove #create-slice-modal"
101
>
102
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
-
>
108
Create Slice
109
-
</button>
110
</div>
111
</form>
112
</div>
113
</div>
114
</div>
115
);
116
-
}
···
1
+
import { Input } from "../../../../shared/fragments/Input.tsx";
2
+
import { Button } from "../../../../shared/fragments/Button.tsx";
3
+
4
interface CreateSliceDialogProps {
5
error?: string;
6
name?: string;
···
57
hx-swap="outerHTML"
58
className="space-y-4"
59
>
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
+
/>
69
70
<div>
71
+
<Input
72
type="text"
73
id="domain"
74
name="domain"
75
+
label="Primary Domain"
76
required
77
+
defaultValue={domain}
78
placeholder="e.g. social.grain"
79
/>
80
<p className="mt-1 text-xs text-gray-500">
···
83
</div>
84
85
<div className="flex justify-end space-x-3 pt-4">
86
+
<Button
87
type="button"
88
+
variant="secondary"
89
_="on click remove #create-slice-modal"
90
>
91
Cancel
92
+
</Button>
93
+
<Button type="submit" variant="primary">
94
Create Slice
95
+
</Button>
96
</div>
97
</form>
98
</div>
99
</div>
100
</div>
101
);
102
+
}
+1
-1
frontend/src/components/EmptyLexiconState.tsx
frontend/src/features/slices/lexicon/templates/fragments/EmptyLexiconState.tsx
+1
-1
frontend/src/components/EmptyLexiconState.tsx
frontend/src/features/slices/lexicon/templates/fragments/EmptyLexiconState.tsx
+13
-7
frontend/src/components/JetstreamStatus.tsx
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatus.tsx
+13
-7
frontend/src/components/JetstreamStatus.tsx
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatus.tsx
···
1
interface JetstreamStatusProps {
2
connected: boolean;
3
status: string;
···
29
</div>
30
<div className="flex items-center gap-3">
31
{sliceId && (
32
-
<a
33
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"
35
>
36
View Logs
37
-
</a>
38
)}
39
</div>
40
</div>
···
60
</div>
61
<div className="flex items-center gap-3">
62
{sliceId && (
63
-
<a
64
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"
66
>
67
View Logs
68
-
</a>
69
)}
70
</div>
71
</div>
72
</div>
73
);
74
}
75
-
}
···
1
+
import { Button } from "../../../../../shared/fragments/Button.tsx";
2
+
3
interface JetstreamStatusProps {
4
connected: boolean;
5
status: string;
···
31
</div>
32
<div className="flex items-center gap-3">
33
{sliceId && (
34
+
<Button
35
href={`/slices/${sliceId}/jetstream/logs`}
36
+
variant="success"
37
+
size="sm"
38
+
className="whitespace-nowrap"
39
>
40
View Logs
41
+
</Button>
42
)}
43
</div>
44
</div>
···
64
</div>
65
<div className="flex items-center gap-3">
66
{sliceId && (
67
+
<Button
68
href={`/slices/${sliceId}/jetstream/logs`}
69
+
variant="danger"
70
+
size="sm"
71
+
className="whitespace-nowrap"
72
>
73
View Logs
74
+
</Button>
75
)}
76
</div>
77
</div>
78
</div>
79
);
80
}
81
+
}
frontend/src/components/JetstreamStatusCompact.tsx
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatusCompact.tsx
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
+25
-16
frontend/src/components/JobHistory.tsx
frontend/src/features/slices/sync/templates/fragments/JobHistory.tsx
···
22
23
function formatDate(dateString: string): string {
24
const date = new Date(dateString);
25
-
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
26
-
hour: '2-digit',
27
-
minute: '2-digit'
28
-
});
29
}
30
31
function extractDurationFromMessage(message: string): string {
32
-
// Extract duration from message like "Sync completed successfully in 424.233625ms"
33
const match = message.match(/in ([\d.]+)(ms|s|m)/);
34
-
if (!match) return 'N/A';
35
-
36
const [, value, unit] = match;
37
const numValue = parseFloat(value);
38
-
39
-
if (unit === 'ms') {
40
if (numValue < 1000) return `${Math.round(numValue)}ms`;
41
return `${(numValue / 1000).toFixed(1)}s`;
42
-
} else if (unit === 's') {
43
if (numValue < 60) return `${numValue}s`;
44
const minutes = Math.floor(numValue / 60);
45
const seconds = Math.round(numValue % 60);
46
return `${minutes}m ${seconds}s`;
47
-
} else if (unit === 'm') {
48
return `${numValue}m`;
49
}
50
-
51
return `${value}${unit}`;
52
}
53
···
76
<div className="flex-1">
77
<div className="flex items-center gap-2 mb-2">
78
{job.result?.success ? (
79
-
<span className="text-green-600 font-medium">✅ Success</span>
80
) : (
81
<span className="text-red-600 font-medium">❌ Failed</span>
82
)}
···
97
<strong>{job.result.totalRecords}</strong> records
98
</span>
99
<span>
100
-
<strong>{job.result.reposProcessed}</strong> repositories
101
</span>
102
<span>
103
-
Collections: <strong>{job.result.collectionsSynced.join(', ')}</strong>
104
</span>
105
</div>
106
{job.result.message && (
···
120
121
<div className="flex flex-col items-end gap-2">
122
<div className="text-xs text-gray-400 font-mono">
123
-
{job.jobId.split('-')[0]}...
124
</div>
125
<a
126
href={`/slices/${sliceId}/sync/logs/${job.jobId}`}
···
22
23
function formatDate(dateString: string): string {
24
const date = new Date(dateString);
25
+
return (
26
+
date.toLocaleDateString() +
27
+
" " +
28
+
date.toLocaleTimeString([], {
29
+
hour: "2-digit",
30
+
minute: "2-digit",
31
+
})
32
+
);
33
}
34
35
function extractDurationFromMessage(message: string): string {
36
const match = message.match(/in ([\d.]+)(ms|s|m)/);
37
+
if (!match) return "N/A";
38
+
39
const [, value, unit] = match;
40
const numValue = parseFloat(value);
41
+
42
+
if (unit === "ms") {
43
if (numValue < 1000) return `${Math.round(numValue)}ms`;
44
return `${(numValue / 1000).toFixed(1)}s`;
45
+
} else if (unit === "s") {
46
if (numValue < 60) return `${numValue}s`;
47
const minutes = Math.floor(numValue / 60);
48
const seconds = Math.round(numValue % 60);
49
return `${minutes}m ${seconds}s`;
50
+
} else if (unit === "m") {
51
return `${numValue}m`;
52
}
53
+
54
return `${value}${unit}`;
55
}
56
···
79
<div className="flex-1">
80
<div className="flex items-center gap-2 mb-2">
81
{job.result?.success ? (
82
+
<span className="text-green-600 font-medium">
83
+
✅ Success
84
+
</span>
85
) : (
86
<span className="text-red-600 font-medium">❌ Failed</span>
87
)}
···
102
<strong>{job.result.totalRecords}</strong> records
103
</span>
104
<span>
105
+
<strong>{job.result.reposProcessed}</strong>{" "}
106
+
repositories
107
</span>
108
<span>
109
+
Collections:{" "}
110
+
<strong>
111
+
{job.result.collectionsSynced.join(", ")}
112
+
</strong>
113
</span>
114
</div>
115
{job.result.message && (
···
129
130
<div className="flex flex-col items-end gap-2">
131
<div className="text-xs text-gray-400 font-mono">
132
+
{job.jobId.split("-")[0]}...
133
</div>
134
<a
135
href={`/slices/${sliceId}/sync/logs/${job.jobId}`}
-96
frontend/src/components/Layout.tsx
-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
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
+11
-8
frontend/src/components/LexiconListItem.tsx
frontend/src/features/slices/lexicon/templates/fragments/LexiconListItem.tsx
···
1
-
import { getRkeyFromUri } from "../utils/at-uri.ts";
2
3
export function LexiconListItem({
4
nsid,
···
29
<p className="text-xs text-gray-400 font-mono">{uri}</p>
30
</div>
31
<div className="flex items-center space-x-2">
32
-
<button
33
type="button"
34
hx-get={`/api/slices/${sliceId}/lexicons/${rkey}/view`}
35
hx-target="#lexicon-modal"
36
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
>
39
<svg
40
className="h-3 w-3 mr-1"
···
56
/>
57
</svg>
58
View
59
-
</button>
60
-
<button
61
type="button"
62
hx-delete={`/api/slices/${sliceId}/lexicons/${rkey}`}
63
hx-target={`#lexicon-${rkey}`}
64
hx-swap="outerHTML"
65
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
>
68
<svg
69
className="h-3 w-3 mr-1"
···
79
/>
80
</svg>
81
Delete
82
-
</button>
83
</div>
84
</div>
85
</div>
86
);
87
-
}
···
1
+
import { getRkeyFromUri } from "../../../../../utils/at-uri.ts";
2
+
import { Button } from "../../../../../shared/fragments/Button.tsx";
3
4
export function LexiconListItem({
5
nsid,
···
30
<p className="text-xs text-gray-400 font-mono">{uri}</p>
31
</div>
32
<div className="flex items-center space-x-2">
33
+
<Button
34
type="button"
35
+
variant="primary"
36
+
size="sm"
37
hx-get={`/api/slices/${sliceId}/lexicons/${rkey}/view`}
38
hx-target="#lexicon-modal"
39
hx-swap="innerHTML"
40
>
41
<svg
42
className="h-3 w-3 mr-1"
···
58
/>
59
</svg>
60
View
61
+
</Button>
62
+
<Button
63
type="button"
64
+
variant="danger"
65
+
size="sm"
66
hx-delete={`/api/slices/${sliceId}/lexicons/${rkey}`}
67
hx-target={`#lexicon-${rkey}`}
68
hx-swap="outerHTML"
69
hx-confirm="Are you sure you want to delete this lexicon?"
70
>
71
<svg
72
className="h-3 w-3 mr-1"
···
82
/>
83
</svg>
84
Delete
85
+
</Button>
86
</div>
87
</div>
88
</div>
89
);
90
+
}
+1
-1
frontend/src/components/LexiconSuccessMessage.tsx
frontend/src/features/slices/lexicon/templates/fragments/LexiconSuccessMessage.tsx
+1
-1
frontend/src/components/LexiconSuccessMessage.tsx
frontend/src/features/slices/lexicon/templates/fragments/LexiconSuccessMessage.tsx
+14
-33
frontend/src/components/LexiconViewModal.tsx
frontend/src/features/slices/lexicon/templates/fragments/LexiconViewModal.tsx
+14
-33
frontend/src/components/LexiconViewModal.tsx
frontend/src/features/slices/lexicon/templates/fragments/LexiconViewModal.tsx
···
1
import { codeToHtml } from "jsr:@shikijs/shiki";
2
-
3
-
interface LexiconViewModalProps {
4
-
nsid: string;
5
-
definitions: string;
6
-
uri: string;
7
-
createdAt: string;
8
-
}
9
10
export async function LexiconViewModal({
11
nsid,
12
definitions,
13
uri,
14
createdAt,
15
-
}: LexiconViewModalProps) {
16
-
// Parse and format the definitions JSON
17
let formattedDefinitions = definitions;
18
try {
19
const parsed = JSON.parse(definitions);
···
22
// Keep original if parsing fails
23
}
24
25
-
// Apply Shiki syntax highlighting
26
const highlightedJson = await codeToHtml(formattedDefinitions, {
27
lang: "json",
28
theme: "catppuccin-latte",
···
31
return (
32
<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
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
34
-
{/* Header */}
35
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
36
<div>
37
<h3 className="text-lg font-semibold text-gray-900 font-mono">
···
63
</button>
64
</div>
65
66
-
{/* Content */}
67
<div className="px-6 py-4 overflow-y-auto max-h-[calc(90vh-120px)]">
68
<div className="mb-4">
69
<label className="block text-sm font-medium text-gray-700 mb-2">
···
75
</div>
76
</div>
77
78
-
{/* Footer */}
79
<div className="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3">
80
-
<button
81
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"
84
>
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
Copy JSON
99
-
</button>
100
<span className="hidden">{formattedDefinitions}</span>
101
-
<button
102
type="button"
103
_="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
>
106
Close
107
-
</button>
108
</div>
109
</div>
110
</div>
···
1
import { codeToHtml } from "jsr:@shikijs/shiki";
2
+
import { Button } from "../../../../../shared/fragments/Button.tsx";
3
4
export async function LexiconViewModal({
5
nsid,
6
definitions,
7
uri,
8
createdAt,
9
+
}: {
10
+
nsid: string;
11
+
definitions: string;
12
+
uri: string;
13
+
createdAt: string;
14
+
}) {
15
let formattedDefinitions = definitions;
16
try {
17
const parsed = JSON.parse(definitions);
···
20
// Keep original if parsing fails
21
}
22
23
const highlightedJson = await codeToHtml(formattedDefinitions, {
24
lang: "json",
25
theme: "catppuccin-latte",
···
28
return (
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">
30
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
31
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
32
<div>
33
<h3 className="text-lg font-semibold text-gray-900 font-mono">
···
59
</button>
60
</div>
61
62
<div className="px-6 py-4 overflow-y-auto max-h-[calc(90vh-120px)]">
63
<div className="mb-4">
64
<label className="block text-sm font-medium text-gray-700 mb-2">
···
70
</div>
71
</div>
72
73
<div className="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3">
74
+
<Button
75
type="button"
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'"
78
>
79
Copy JSON
80
+
</Button>
81
<span className="hidden">{formattedDefinitions}</span>
82
+
<Button
83
type="button"
84
+
variant="primary"
85
_="on click set #lexicon-modal's innerHTML to ''"
86
>
87
Close
88
+
</Button>
89
</div>
90
</div>
91
</div>
-370
frontend/src/components/OAuthClientModal.tsx
-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 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
-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
-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
-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
+1
-1
frontend/src/components/SettingsResult.tsx
frontend/src/features/settings/templates/fragments/SettingsResult.tsx
-121
frontend/src/components/SyncJobLogs.tsx
-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
+1
-1
frontend/src/components/SyncResult.tsx
frontend/src/features/slices/sync/templates/fragments/SyncResult.tsx
-32
frontend/src/components/UpdateResult.tsx
-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
+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
+11
frontend/src/features/auth/templates/fragments/ErrorAlert.tsx
···
+32
frontend/src/features/auth/templates/fragments/LoginForm.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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 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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
frontend/src/lib/request-logger.ts
frontend/src/lib/request_logger.ts
+2
-2
frontend/src/main.ts
+2
-2
frontend/src/main.ts
-112
frontend/src/pages/IndexPage.tsx
-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
+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";
5
6
interface JetstreamLogsPageProps {
7
logs: LogEntry[];
8
sliceId: string;
9
-
currentUser?: { handle?: string; isAuthenticated: boolean };
10
}
11
12
export function JetstreamLogsPage({
···
41
</div>
42
</Layout>
43
);
44
-
}
···
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";
6
7
interface JetstreamLogsPageProps {
8
logs: LogEntry[];
9
sliceId: string;
10
+
currentUser?: AuthenticatedUser;
11
}
12
13
export function JetstreamLogsPage({
···
42
</div>
43
</Layout>
44
);
45
+
}
-84
frontend/src/pages/LoginPage.tsx
-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
-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
+4
-6
frontend/src/pages/SliceApiDocsPage.tsx
frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx
···
1
interface SliceApiDocsPageProps {
2
sliceId: string;
3
sliceName: string;
4
accessToken?: string;
5
-
currentUser: {
6
-
isAuthenticated: boolean;
7
-
username?: string;
8
-
sub?: string;
9
-
};
10
}
11
12
export function SliceApiDocsPage(props: SliceApiDocsPageProps) {
···
188
</body>
189
</html>
190
);
191
-
}
···
1
+
import type { AuthenticatedUser } from "../../../../routes/middleware.ts";
2
+
3
interface SliceApiDocsPageProps {
4
sliceId: string;
5
sliceName: string;
6
accessToken?: string;
7
+
currentUser: AuthenticatedUser;
8
}
9
10
export function SliceApiDocsPage(props: SliceApiDocsPageProps) {
···
186
</body>
187
</html>
188
);
189
+
}
+5
-7
frontend/src/pages/SliceCodegenPage.tsx
frontend/src/features/slices/codegen/templates/SliceCodegenPage.tsx
+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";
4
5
interface SliceCodegenPageProps {
6
sliceName?: string;
7
sliceId?: string;
8
-
currentUser?: { handle?: string; isAuthenticated: boolean };
9
}
10
11
export function SliceCodegenPage({
···
13
sliceId = "example",
14
currentUser,
15
}: SliceCodegenPageProps) {
16
-
17
return (
18
<Layout title={`${sliceName} - Code Generation`} currentUser={currentUser}>
19
<div>
···
31
</div>
32
</div>
33
34
-
{/* Tab Navigation */}
35
<SliceTabs sliceId={sliceId} currentTab="codegen" />
36
37
<CodegenForm sliceId={sliceId} />
38
-
39
40
<div className="mt-6 bg-green-50 border border-green-200 rounded-lg p-6">
41
<h3 className="text-lg font-semibold text-green-800 mb-2">
···
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";
5
6
interface SliceCodegenPageProps {
7
sliceName?: string;
8
sliceId?: string;
9
+
currentUser?: AuthenticatedUser;
10
}
11
12
export function SliceCodegenPage({
···
14
sliceId = "example",
15
currentUser,
16
}: SliceCodegenPageProps) {
17
return (
18
<Layout title={`${sliceName} - Code Generation`} currentUser={currentUser}>
19
<div>
···
31
</div>
32
</div>
33
34
<SliceTabs sliceId={sliceId} currentTab="codegen" />
35
36
<CodegenForm sliceId={sliceId} />
37
38
<div className="mt-6 bg-green-50 border border-green-200 rounded-lg p-6">
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
+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";
4
5
interface SliceLexiconPageProps {
6
sliceName?: string;
7
sliceId?: string;
8
-
currentUser?: { handle?: string; isAuthenticated: boolean };
9
}
10
11
export function SliceLexiconPage({
···
13
sliceId = "example",
14
currentUser,
15
}: SliceLexiconPageProps) {
16
-
17
return (
18
<Layout title={`${sliceName} - Lexicons`} currentUser={currentUser}>
19
<div>
···
26
</div>
27
</div>
28
29
-
{/* Tab Navigation */}
30
<SliceTabs sliceId={sliceId} currentTab="lexicon" />
31
32
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
···
45
className="space-y-4"
46
>
47
<div>
48
-
<label className="block text-sm font-medium text-gray-700 mb-2">
49
-
Lexicon JSON
50
-
</label>
51
-
<textarea
52
name="lexicon_json"
53
rows={12}
54
-
className="block w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm"
55
placeholder={`{
56
"lexicon": 1,
57
"id": "social.slices.example",
···
84
</p>
85
</div>
86
87
-
<button
88
-
type="submit"
89
-
className="bg-purple-500 hover:bg-purple-600 text-white px-6 py-2 rounded-md"
90
-
>
91
Add Lexicon
92
-
</button>
93
</form>
94
95
<div id="lexicon-result" className="mt-4"></div>
96
</div>
97
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
<div className="bg-white rounded-lg shadow-md">
139
<div className="px-6 py-4 border-b border-gray-200">
140
<h2 className="text-lg font-semibold text-gray-800">
···
166
</ul>
167
</div>
168
169
-
{/* Modal container for viewing lexicons */}
170
<div id="lexicon-modal"></div>
171
</div>
172
</Layout>
···
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";
7
8
interface SliceLexiconPageProps {
9
sliceName?: string;
10
sliceId?: string;
11
+
currentUser?: AuthenticatedUser;
12
}
13
14
export function SliceLexiconPage({
···
16
sliceId = "example",
17
currentUser,
18
}: SliceLexiconPageProps) {
19
return (
20
<Layout title={`${sliceName} - Lexicons`} currentUser={currentUser}>
21
<div>
···
28
</div>
29
</div>
30
31
<SliceTabs sliceId={sliceId} currentTab="lexicon" />
32
33
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
···
46
className="space-y-4"
47
>
48
<div>
49
+
<Textarea
50
+
label="Lexicon JSON"
51
name="lexicon_json"
52
rows={12}
53
+
class="font-mono text-sm"
54
placeholder={`{
55
"lexicon": 1,
56
"id": "social.slices.example",
···
83
</p>
84
</div>
85
86
+
<Button type="submit" variant="purple">
87
Add Lexicon
88
+
</Button>
89
</form>
90
91
<div id="lexicon-result" className="mt-4"></div>
92
</div>
93
94
<div className="bg-white rounded-lg shadow-md">
95
<div className="px-6 py-4 border-b border-gray-200">
96
<h2 className="text-lg font-semibold text-gray-800">
···
122
</ul>
123
</div>
124
125
<div id="lexicon-modal"></div>
126
</div>
127
</Layout>
-174
frontend/src/pages/SliceOAuthPage.tsx
-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
+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";
3
4
interface Collection {
5
name: string;
···
7
actors?: number;
8
}
9
10
-
interface SlicePageProps {
11
totalRecords?: number;
12
totalActors?: number;
13
totalLexicons?: number;
···
15
sliceName?: string;
16
sliceId?: string;
17
currentTab?: string;
18
-
currentUser?: { handle?: string; isAuthenticated: boolean };
19
}
20
21
-
export function SlicePage({
22
totalRecords = 0,
23
totalActors = 0,
24
totalLexicons = 0,
···
27
sliceId = "example",
28
currentTab = "overview",
29
currentUser,
30
-
}: SlicePageProps) {
31
return (
32
<Layout title={sliceName} currentUser={currentUser}>
33
<div>
···
40
</div>
41
</div>
42
43
-
{/* Tab Navigation */}
44
<SliceTabs sliceId={sliceId} currentTab={currentTab} />
45
46
-
{/* Jetstream Status */}
47
<div
48
hx-get={`/api/jetstream/status?sliceId=${sliceId}`}
49
hx-trigger="load, every 2m"
···
99
<p className="text-gray-600 mb-4">
100
View lexicon definitions and schemas that define your slice.
101
</p>
102
-
<a
103
href={`/slices/${sliceId}/lexicon`}
104
-
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded"
105
>
106
View Lexicons
107
-
</a>
108
</div>
109
110
<div className="bg-white rounded-lg shadow-md p-6">
···
115
Browse indexed AT Protocol records by collection.
116
</p>
117
{collections.length > 0 ? (
118
-
<a
119
href={`/slices/${sliceId}/records`}
120
-
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
121
>
122
Browse Records
123
-
</a>
124
) : (
125
<p className="text-gray-500 text-sm">
126
No records synced yet. Start by syncing some records!
···
135
<p className="text-gray-600 mb-4">
136
Generate TypeScript client from your lexicon definitions.
137
</p>
138
-
<a
139
href={`/slices/${sliceId}/codegen`}
140
-
className="bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded"
141
>
142
Generate Client
143
-
</a>
144
</div>
145
146
<div className="bg-white rounded-lg shadow-md p-6">
···
150
<p className="text-gray-600 mb-4">
151
Interactive OpenAPI documentation for your slice's XRPC endpoints.
152
</p>
153
-
<a
154
href={`/slices/${sliceId}/api-docs`}
155
-
className="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded"
156
>
157
View API Docs
158
-
</a>
159
</div>
160
161
<div className="bg-white rounded-lg shadow-md p-6">
···
165
<p className="text-gray-600 mb-4">
166
Sync entire collections from AT Protocol network.
167
</p>
168
-
<a
169
href={`/slices/${sliceId}/sync`}
170
-
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
171
>
172
Start Sync
173
-
</a>
174
</div>
175
176
{collections.length > 0 ? (
···
227
</div>
228
</Layout>
229
);
230
-
}
···
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
6
interface Collection {
7
name: string;
···
9
actors?: number;
10
}
11
12
+
interface SliceOverviewProps {
13
totalRecords?: number;
14
totalActors?: number;
15
totalLexicons?: number;
···
17
sliceName?: string;
18
sliceId?: string;
19
currentTab?: string;
20
+
currentUser?: AuthenticatedUser;
21
}
22
23
+
export function SliceOverview({
24
totalRecords = 0,
25
totalActors = 0,
26
totalLexicons = 0,
···
29
sliceId = "example",
30
currentTab = "overview",
31
currentUser,
32
+
}: SliceOverviewProps) {
33
return (
34
<Layout title={sliceName} currentUser={currentUser}>
35
<div>
···
42
</div>
43
</div>
44
45
<SliceTabs sliceId={sliceId} currentTab={currentTab} />
46
47
<div
48
hx-get={`/api/jetstream/status?sliceId=${sliceId}`}
49
hx-trigger="load, every 2m"
···
99
<p className="text-gray-600 mb-4">
100
View lexicon definitions and schemas that define your slice.
101
</p>
102
+
<Button
103
href={`/slices/${sliceId}/lexicon`}
104
+
variant="purple"
105
>
106
View Lexicons
107
+
</Button>
108
</div>
109
110
<div className="bg-white rounded-lg shadow-md p-6">
···
115
Browse indexed AT Protocol records by collection.
116
</p>
117
{collections.length > 0 ? (
118
+
<Button
119
href={`/slices/${sliceId}/records`}
120
+
variant="primary"
121
>
122
Browse Records
123
+
</Button>
124
) : (
125
<p className="text-gray-500 text-sm">
126
No records synced yet. Start by syncing some records!
···
135
<p className="text-gray-600 mb-4">
136
Generate TypeScript client from your lexicon definitions.
137
</p>
138
+
<Button
139
href={`/slices/${sliceId}/codegen`}
140
+
variant="warning"
141
>
142
Generate Client
143
+
</Button>
144
</div>
145
146
<div className="bg-white rounded-lg shadow-md p-6">
···
150
<p className="text-gray-600 mb-4">
151
Interactive OpenAPI documentation for your slice's XRPC endpoints.
152
</p>
153
+
<Button
154
href={`/slices/${sliceId}/api-docs`}
155
+
variant="indigo"
156
>
157
View API Docs
158
+
</Button>
159
</div>
160
161
<div className="bg-white rounded-lg shadow-md p-6">
···
165
<p className="text-gray-600 mb-4">
166
Sync entire collections from AT Protocol network.
167
</p>
168
+
<Button
169
href={`/slices/${sliceId}/sync`}
170
+
variant="success"
171
>
172
Start Sync
173
+
</Button>
174
</div>
175
176
{collections.length > 0 ? (
···
227
</div>
228
</Layout>
229
);
230
+
}
-232
frontend/src/pages/SliceRecordsPage.tsx
-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
+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";
3
4
-
interface SliceSettingsPageProps {
5
sliceName?: string;
6
sliceDomain?: string;
7
sliceId?: string;
8
updated?: boolean;
9
error?: string | null;
10
-
currentUser?: { handle?: string; isAuthenticated: boolean };
11
}
12
13
-
export function SliceSettingsPage({
14
sliceName = "My Slice",
15
sliceDomain = "",
16
sliceId = "example",
17
updated = false,
18
error = null,
19
currentUser,
20
-
}: SliceSettingsPageProps) {
21
return (
22
<Layout title={`${sliceName} - Settings`} currentUser={currentUser}>
23
<div>
···
63
hx-swap="innerHTML"
64
className="space-y-4"
65
>
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>
83
84
<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
92
type="text"
93
id="slice-domain"
94
name="domain"
95
value={sliceDomain}
96
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
placeholder="e.g. social.grain"
99
/>
100
<p className="mt-1 text-xs text-gray-500">
···
103
</div>
104
105
<div className="flex justify-start">
106
-
<button
107
type="submit"
108
-
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-md font-medium"
109
>
110
Update Settings
111
-
</button>
112
</div>
113
<div id="settings-form-result" className="mt-4"></div>
114
</form>
···
123
Permanently delete this slice and all associated data. This action
124
cannot be undone.
125
</p>
126
-
<button
127
type="button"
128
hx-delete={`/api/slices/${sliceId}`}
129
hx-confirm="Are you sure you want to delete this slice? This action cannot be undone."
130
hx-target="body"
131
hx-push-url="/"
132
-
className="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-md font-medium"
133
>
134
Delete Slice
135
-
</button>
136
</div>
137
</div>
138
</div>
139
</Layout>
140
);
141
-
}
···
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";
6
7
+
interface SliceSettingsProps {
8
sliceName?: string;
9
sliceDomain?: string;
10
sliceId?: string;
11
updated?: boolean;
12
error?: string | null;
13
+
currentUser?: AuthenticatedUser;
14
}
15
16
+
export function SliceSettings({
17
sliceName = "My Slice",
18
sliceDomain = "",
19
sliceId = "example",
20
updated = false,
21
error = null,
22
currentUser,
23
+
}: SliceSettingsProps) {
24
return (
25
<Layout title={`${sliceName} - Settings`} currentUser={currentUser}>
26
<div>
···
66
hx-swap="innerHTML"
67
className="space-y-4"
68
>
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
+
/>
78
79
<div>
80
+
<Input
81
+
label="Primary Domain"
82
type="text"
83
id="slice-domain"
84
name="domain"
85
value={sliceDomain}
86
required
87
placeholder="e.g. social.grain"
88
/>
89
<p className="mt-1 text-xs text-gray-500">
···
92
</div>
93
94
<div className="flex justify-start">
95
+
<Button
96
type="submit"
97
+
variant="primary"
98
+
size="lg"
99
>
100
Update Settings
101
+
</Button>
102
</div>
103
<div id="settings-form-result" className="mt-4"></div>
104
</form>
···
113
Permanently delete this slice and all associated data. This action
114
cannot be undone.
115
</p>
116
+
<Button
117
type="button"
118
hx-delete={`/api/slices/${sliceId}`}
119
hx-confirm="Are you sure you want to delete this slice? This action cannot be undone."
120
hx-target="body"
121
hx-push-url="/"
122
+
variant="danger"
123
+
size="lg"
124
>
125
Delete Slice
126
+
</Button>
127
</div>
128
</div>
129
</div>
130
</Layout>
131
);
132
+
}
-170
frontend/src/pages/SliceSyncPage.tsx
-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
+3
-2
frontend/src/pages/SyncJobLogsPage.tsx
frontend/src/features/slices/sync-logs/templates/SyncJobLogsPage.tsx
···
1
+
import { Layout } from "../../../../shared/fragments/Layout.tsx";
2
+
import type { AuthenticatedUser } from "../../../../routes/middleware.ts";
3
4
interface SyncJobLogsPageProps {
5
sliceName?: string;
6
sliceId?: string;
7
jobId?: string;
8
+
currentUser?: AuthenticatedUser;
9
}
10
11
export function SyncJobLogsPage({
-24
frontend/src/routes/dialogs.tsx
-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
-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
+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
+48
-6
frontend/src/routes/oauth.ts
frontend/src/features/auth/handlers.tsx
···
1
import type { Route } from "@std/http/unstable-route";
2
-
import { atprotoClient, oauthSessions, sessionStore } from "../config.ts";
3
4
async function handleOAuthAuthorize(req: Request): Promise<Response> {
5
try {
···
83
// Create session cookie
84
const sessionCookie = sessionStore.createSessionCookie(sessionId);
85
86
// Sync external collections if user doesn't have them yet
87
try {
88
-
// Get user info from OAuth session
89
-
const userInfo = await atprotoClient.oauth?.getUserInfo();
90
if (!userInfo?.sub) {
91
console.log(
92
"No user DID available, skipping external collections sync"
···
97
const profileCheck =
98
await atprotoClient.app.bsky.actor.profile.getRecords({
99
where: {
100
-
did: { eq: userInfo.sub }
101
},
102
limit: 1,
103
});
···
123
);
124
}
125
126
return new Response(null, {
127
status: 302,
128
headers: {
129
-
Location: new URL("/", req.url).toString(),
130
"Set-Cookie": sessionCookie,
131
},
132
});
···
163
});
164
}
165
166
-
export const oauthRoutes: Route[] = [
167
{
168
method: "POST",
169
pattern: new URLPattern({ pathname: "/oauth/authorize" }),
···
174
pattern: new URLPattern({ pathname: "/oauth/callback" }),
175
handler: handleOAuthCallback,
176
},
177
{
178
method: "POST",
179
pattern: new URLPattern({ pathname: "/logout" }),
···
1
import type { Route } from "@std/http/unstable-route";
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
+
// ============================================================================
24
25
async function handleOAuthAuthorize(req: Request): Promise<Response> {
26
try {
···
104
// Create session cookie
105
const sessionCookie = sessionStore.createSessionCookie(sessionId);
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
+
115
// Sync external collections if user doesn't have them yet
116
try {
117
if (!userInfo?.sub) {
118
console.log(
119
"No user DID available, skipping external collections sync"
···
124
const profileCheck =
125
await atprotoClient.app.bsky.actor.profile.getRecords({
126
where: {
127
+
did: { eq: userInfo.sub },
128
},
129
limit: 1,
130
});
···
150
);
151
}
152
153
+
// Redirect to user's profile page if handle is available
154
+
const redirectPath = userInfo?.name ? `/profile/${userInfo.name}` : "/";
155
+
156
return new Response(null, {
157
status: 302,
158
headers: {
159
+
Location: new URL(redirectPath, req.url).toString(),
160
"Set-Cookie": sessionCookie,
161
},
162
});
···
193
});
194
}
195
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
208
{
209
method: "POST",
210
pattern: new URLPattern({ pathname: "/oauth/authorize" }),
···
215
pattern: new URLPattern({ pathname: "/oauth/callback" }),
216
handler: handleOAuthCallback,
217
},
218
+
// Logout
219
{
220
method: "POST",
221
pattern: new URLPattern({ pathname: "/logout" }),
-728
frontend/src/routes/pages.tsx
-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
-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
-
];
···
+6
frontend/src/utils/cn.ts
+6
frontend/src/utils/cn.ts
+14
frontend/src/utils/htmx.ts
+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
+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
+
}