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