Highly ambitious ATProtocol AppView service and sdks

improve waitlist ui with search and load more, update getActors in api to support same where as getRecords

+687 -272
+146 -190
api/src/database/actors.rs
··· 4 //! tracked within slices, including batch insertion, querying, and filtering. 5 6 use super::client::Database; 7 - use super::types::WhereCondition; 8 use crate::errors::DatabaseError; 9 use crate::models::Actor; 10 - use std::collections::HashMap; 11 12 impl Database { 13 /// Inserts multiple actors in batches with conflict resolution. ··· 46 Ok(()) 47 } 48 49 - /// Queries actors for a slice with optional filtering and cursor-based pagination. 50 /// 51 - /// Supports filtering by: 52 - /// - handle (exact match or contains) 53 - /// - did (exact match or IN clause) 54 /// 55 /// # Returns 56 /// Tuple of (actors, next_cursor) where cursor is the last DID ··· 59 slice_uri: &str, 60 limit: Option<i32>, 61 cursor: Option<&str>, 62 - where_conditions: Option<&HashMap<String, WhereCondition>>, 63 ) -> Result<(Vec<Actor>, Option<String>), DatabaseError> { 64 let limit = limit.unwrap_or(50).min(100); 65 66 - let records = if let Some(conditions) = where_conditions { 67 - if let Some(handle_condition) = conditions.get("handle") { 68 - if let Some(contains_value) = &handle_condition.contains { 69 - let pattern = format!("%{}%", contains_value); 70 - if let Some(cursor_did) = cursor { 71 - sqlx::query_as!( 72 - Actor, 73 - r#" 74 - SELECT did, handle, slice_uri, indexed_at 75 - FROM actor 76 - WHERE slice_uri = $1 AND handle ILIKE $2 AND did > $3 77 - ORDER BY did ASC 78 - LIMIT $4 79 - "#, 80 - slice_uri, 81 - pattern, 82 - cursor_did, 83 - limit as i64 84 - ) 85 - .fetch_all(&self.pool) 86 - .await? 87 - } else { 88 - sqlx::query_as!( 89 - Actor, 90 - r#" 91 - SELECT did, handle, slice_uri, indexed_at 92 - FROM actor 93 - WHERE slice_uri = $1 AND handle ILIKE $2 94 - ORDER BY did ASC 95 - LIMIT $3 96 - "#, 97 - slice_uri, 98 - pattern, 99 - limit as i64 100 - ) 101 - .fetch_all(&self.pool) 102 - .await? 103 - } 104 - } else if let Some(eq_value) = &handle_condition.eq { 105 - let handle_str = eq_value.as_str().unwrap_or(""); 106 - if let Some(cursor_did) = cursor { 107 - sqlx::query_as!( 108 - Actor, 109 - r#" 110 - SELECT did, handle, slice_uri, indexed_at 111 - FROM actor 112 - WHERE slice_uri = $1 AND handle = $2 AND did > $3 113 - ORDER BY did ASC 114 - LIMIT $4 115 - "#, 116 - slice_uri, 117 - handle_str, 118 - cursor_did, 119 - limit as i64 120 - ) 121 - .fetch_all(&self.pool) 122 - .await? 123 } else { 124 - sqlx::query_as!( 125 - Actor, 126 - r#" 127 - SELECT did, handle, slice_uri, indexed_at 128 - FROM actor 129 - WHERE slice_uri = $1 AND handle = $2 130 - ORDER BY did ASC 131 - LIMIT $3 132 - "#, 133 - slice_uri, 134 - handle_str, 135 - limit as i64 136 - ) 137 - .fetch_all(&self.pool) 138 - .await? 139 } 140 - } else { 141 - self.query_actors_with_cursor(slice_uri, cursor, limit) 142 - .await? 143 } 144 - } else if let Some(did_condition) = conditions.get("did") { 145 - if let Some(in_values) = &did_condition.in_values { 146 - let string_values: Vec<String> = in_values 147 .iter() 148 - .filter_map(|v| v.as_str()) 149 - .map(|s| s.to_string()) 150 .collect(); 151 152 - sqlx::query_as!( 153 - Actor, 154 - r#" 155 - SELECT did, handle, slice_uri, indexed_at 156 - FROM actor 157 - WHERE slice_uri = $1 AND did = ANY($2) 158 - ORDER BY did ASC 159 - LIMIT $3 160 - "#, 161 - slice_uri, 162 - &string_values, 163 - limit as i64 164 - ) 165 - .fetch_all(&self.pool) 166 - .await? 167 - } else if let Some(eq_value) = &did_condition.eq { 168 - let did_str = eq_value.as_str().unwrap_or(""); 169 - if let Some(cursor_did) = cursor { 170 - sqlx::query_as!( 171 - Actor, 172 - r#" 173 - SELECT did, handle, slice_uri, indexed_at 174 - FROM actor 175 - WHERE slice_uri = $1 AND did = $2 AND did > $3 176 - ORDER BY did ASC 177 - LIMIT $4 178 - "#, 179 - slice_uri, 180 - did_str, 181 - cursor_did, 182 - limit as i64 183 - ) 184 - .fetch_all(&self.pool) 185 - .await? 186 - } else { 187 - sqlx::query_as!( 188 - Actor, 189 - r#" 190 - SELECT did, handle, slice_uri, indexed_at 191 - FROM actor 192 - WHERE slice_uri = $1 AND did = $2 193 - ORDER BY did ASC 194 - LIMIT $3 195 - "#, 196 - slice_uri, 197 - did_str, 198 - limit as i64 199 - ) 200 - .fetch_all(&self.pool) 201 - .await? 202 } 203 - } else { 204 - self.query_actors_with_cursor(slice_uri, cursor, limit) 205 - .await? 206 } 207 - } else { 208 - self.query_actors_with_cursor(slice_uri, cursor, limit) 209 - .await? 210 } 211 - } else { 212 - self.query_actors_with_cursor(slice_uri, cursor, limit) 213 - .await? 214 - }; 215 216 - let cursor = if records.is_empty() { 217 - None 218 } else { 219 records.last().map(|actor| actor.did.clone()) 220 }; ··· 222 Ok((records, cursor)) 223 } 224 225 - /// Internal helper for basic actor queries with cursor pagination. 226 - async fn query_actors_with_cursor( 227 - &self, 228 - slice_uri: &str, 229 - cursor: Option<&str>, 230 - limit: i32, 231 - ) -> Result<Vec<Actor>, DatabaseError> { 232 - match cursor { 233 - Some(cursor_did) => sqlx::query_as!( 234 - Actor, 235 - r#" 236 - SELECT did, handle, slice_uri, indexed_at 237 - FROM actor 238 - WHERE slice_uri = $1 AND did > $2 239 - ORDER BY did ASC 240 - LIMIT $3 241 - "#, 242 - slice_uri, 243 - cursor_did, 244 - limit as i64 245 - ) 246 - .fetch_all(&self.pool) 247 - .await 248 - .map_err(DatabaseError::from), 249 - None => sqlx::query_as!( 250 - Actor, 251 - r#" 252 - SELECT did, handle, slice_uri, indexed_at 253 - FROM actor 254 - WHERE slice_uri = $1 255 - ORDER BY did ASC 256 - LIMIT $2 257 - "#, 258 - slice_uri, 259 - limit as i64 260 - ) 261 - .fetch_all(&self.pool) 262 - .await 263 - .map_err(DatabaseError::from), 264 - } 265 - } 266 267 /// Gets all actors across all slices. 268 /// ··· 324 Ok(result.rows_affected()) 325 } 326 }
··· 4 //! tracked within slices, including batch insertion, querying, and filtering. 5 6 use super::client::Database; 7 + use super::types::{WhereClause, WhereCondition}; 8 use crate::errors::DatabaseError; 9 use crate::models::Actor; 10 11 impl Database { 12 /// Inserts multiple actors in batches with conflict resolution. ··· 45 Ok(()) 46 } 47 48 + /// Queries actors for a slice with advanced filtering and cursor-based pagination. 49 /// 50 + /// Supports: 51 + /// - Complex WHERE conditions (AND/OR, eq/in/contains operators) 52 + /// - Cursor-based pagination 53 /// 54 /// # Returns 55 /// Tuple of (actors, next_cursor) where cursor is the last DID ··· 58 slice_uri: &str, 59 limit: Option<i32>, 60 cursor: Option<&str>, 61 + where_clause: Option<&WhereClause>, 62 ) -> Result<(Vec<Actor>, Option<String>), DatabaseError> { 63 let limit = limit.unwrap_or(50).min(100); 64 65 + let mut where_clauses = vec![format!("slice_uri = $1")]; 66 + let mut param_count = 2; 67 + 68 + // Build WHERE conditions for actors (handle table columns properly) 69 + let (and_conditions, or_conditions) = build_actor_where_conditions(where_clause, &mut param_count); 70 + where_clauses.extend(and_conditions); 71 + 72 + if !or_conditions.is_empty() { 73 + let or_clause = format!("({})", or_conditions.join(" OR ")); 74 + where_clauses.push(or_clause); 75 + } 76 + 77 + // Add cursor condition 78 + if let Some(_cursor_did) = cursor { 79 + where_clauses.push(format!("did > ${}", param_count)); 80 + param_count += 1; 81 + } 82 + 83 + let where_sql = format!("WHERE {}", where_clauses.join(" AND ")); 84 + 85 + let query = format!( 86 + r#" 87 + SELECT did, handle, slice_uri, indexed_at 88 + FROM actor 89 + {} 90 + ORDER BY did ASC 91 + LIMIT ${} 92 + "#, 93 + where_sql, 94 + param_count 95 + ); 96 + 97 + let mut sqlx_query = sqlx::query_as::<_, Actor>(&query); 98 + 99 + // Bind parameters in order 100 + sqlx_query = sqlx_query.bind(slice_uri); 101 + 102 + // Bind WHERE clause parameters 103 + if let Some(clause) = where_clause { 104 + for condition in clause.conditions.values() { 105 + if let Some(eq_value) = &condition.eq { 106 + if let Some(str_val) = eq_value.as_str() { 107 + sqlx_query = sqlx_query.bind(str_val); 108 } else { 109 + sqlx_query = sqlx_query.bind(eq_value); 110 } 111 } 112 + if let Some(in_values) = &condition.in_values { 113 + let str_values: Vec<String> = in_values 114 .iter() 115 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 116 .collect(); 117 + sqlx_query = sqlx_query.bind(str_values); 118 + } 119 + if let Some(contains_value) = &condition.contains { 120 + sqlx_query = sqlx_query.bind(contains_value); 121 + } 122 + } 123 124 + // Bind OR conditions 125 + if let Some(or_conditions) = &clause.or_conditions { 126 + for condition in or_conditions.values() { 127 + if let Some(eq_value) = &condition.eq { 128 + if let Some(str_val) = eq_value.as_str() { 129 + sqlx_query = sqlx_query.bind(str_val); 130 + } else { 131 + sqlx_query = sqlx_query.bind(eq_value); 132 + } 133 } 134 + if let Some(in_values) = &condition.in_values { 135 + let str_values: Vec<String> = in_values 136 + .iter() 137 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 138 + .collect(); 139 + sqlx_query = sqlx_query.bind(str_values); 140 + } 141 + if let Some(contains_value) = &condition.contains { 142 + sqlx_query = sqlx_query.bind(contains_value); 143 + } 144 } 145 } 146 + } 147 148 + // Bind cursor parameter 149 + if let Some(cursor_did) = cursor { 150 + sqlx_query = sqlx_query.bind(cursor_did); 151 + } 152 + 153 + // Bind limit 154 + sqlx_query = sqlx_query.bind(limit as i64); 155 + 156 + let records = sqlx_query.fetch_all(&self.pool).await?; 157 + 158 + let cursor = if records.len() < limit as usize { 159 + None // Last page - no more results 160 } else { 161 records.last().map(|actor| actor.did.clone()) 162 }; ··· 164 Ok((records, cursor)) 165 } 166 167 168 /// Gets all actors across all slices. 169 /// ··· 225 Ok(result.rows_affected()) 226 } 227 } 228 + 229 + /// Builds WHERE conditions specifically for actor queries. 230 + /// 231 + /// Unlike the general query builder, this handles actor table columns directly 232 + /// rather than treating them as JSON paths. 233 + fn build_actor_where_conditions( 234 + where_clause: Option<&WhereClause>, 235 + param_count: &mut usize, 236 + ) -> (Vec<String>, Vec<String>) { 237 + let mut where_clauses = Vec::new(); 238 + let mut or_clauses = Vec::new(); 239 + 240 + if let Some(clause) = where_clause { 241 + for (field, condition) in &clause.conditions { 242 + let field_clause = build_actor_single_condition(field, condition, param_count); 243 + if !field_clause.is_empty() { 244 + where_clauses.push(field_clause); 245 + } 246 + } 247 + 248 + if let Some(or_conditions) = &clause.or_conditions { 249 + for (field, condition) in or_conditions { 250 + let field_clause = build_actor_single_condition(field, condition, param_count); 251 + if !field_clause.is_empty() { 252 + or_clauses.push(field_clause); 253 + } 254 + } 255 + } 256 + } 257 + 258 + (where_clauses, or_clauses) 259 + } 260 + 261 + /// Builds a single SQL condition clause for actor fields. 262 + fn build_actor_single_condition( 263 + field: &str, 264 + condition: &WhereCondition, 265 + param_count: &mut usize, 266 + ) -> String { 267 + if let Some(_eq_value) = &condition.eq { 268 + let clause = format!("{} = ${}", field, param_count); 269 + *param_count += 1; 270 + clause 271 + } else if let Some(_in_values) = &condition.in_values { 272 + let clause = format!("{} = ANY(${})", field, param_count); 273 + *param_count += 1; 274 + clause 275 + } else if let Some(_contains_value) = &condition.contains { 276 + let clause = format!("{} ILIKE '%' || ${} || '%'", field, param_count); 277 + *param_count += 1; 278 + clause 279 + } else { 280 + String::new() 281 + } 282 + }
+3 -2
api/src/database/records.rs
··· 342 343 let records = query_builder.fetch_all(&self.pool).await?; 344 345 - let cursor = if records.is_empty() { 346 - None 347 } else { 348 records 349 .last()
··· 342 343 let records = query_builder.fetch_all(&self.pool).await?; 344 345 + // Only return cursor if we got a full page, indicating there might be more 346 + let cursor = if records.len() < limit as usize { 347 + None // Last page - no more results 348 } else { 349 records 350 .last()
+4 -4
api/src/xrpc/network/slices/slice/get_actors.rs
··· 1 use crate::{ 2 AppState, 3 errors::AppError, 4 - models::{Actor, WhereCondition}, 5 }; 6 use axum::{extract::State, response::Json}; 7 use serde::{Deserialize, Serialize}; 8 - use std::collections::HashMap; 9 10 #[derive(Debug, Deserialize)] 11 #[serde(rename_all = "camelCase")] ··· 14 pub limit: Option<i32>, 15 pub cursor: Option<String>, 16 #[serde(rename = "where")] 17 - pub where_conditions: Option<HashMap<String, WhereCondition>>, 18 } 19 20 #[derive(Debug, Serialize)] ··· 34 &params.slice, 35 params.limit, 36 params.cursor.as_deref(), 37 - params.where_conditions.as_ref(), 38 ) 39 .await 40 .map_err(|e| AppError::Internal(format!("Failed to fetch actors: {}", e)))?;
··· 1 use crate::{ 2 AppState, 3 + database::types::WhereClause, 4 errors::AppError, 5 + models::Actor, 6 }; 7 use axum::{extract::State, response::Json}; 8 use serde::{Deserialize, Serialize}; 9 10 #[derive(Debug, Deserialize)] 11 #[serde(rename_all = "camelCase")] ··· 14 pub limit: Option<i32>, 15 pub cursor: Option<String>, 16 #[serde(rename = "where")] 17 + pub where_clause: Option<WhereClause>, 18 } 19 20 #[derive(Debug, Serialize)] ··· 34 &params.slice, 35 params.limit, 36 params.cursor.as_deref(), 37 + params.where_clause.as_ref(), 38 ) 39 .await 40 .map_err(|e| AppError::Internal(format!("Failed to fetch actors: {}", e)))?;
+5 -1
frontend/src/client.ts
··· 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-26 18:40:59 UTC 3 // Lexicons: 40 4 5 /** ··· 984 indexedActorCount?: number; 985 /** Number of collections with indexed records */ 986 indexedCollectionCount?: number; 987 } 988 989 export interface NetworkSlicesSliceDefsSparklinePoint {
··· 1 // Generated TypeScript client for AT Protocol records 2 + // Generated at: 2025-09-28 00:41:14 UTC 3 // Lexicons: 40 4 5 /** ··· 984 indexedActorCount?: number; 985 /** Number of collections with indexed records */ 986 indexedCollectionCount?: number; 987 + /** Total number of waitlist requests for this slice */ 988 + waitlistRequestCount?: number; 989 + /** Total number of waitlist invites for this slice */ 990 + waitlistInviteCount?: number; 991 } 992 993 export interface NetworkSlicesSliceDefsSparklinePoint {
+116 -20
frontend/src/features/slices/waitlist/api.ts
··· 7 AppBskyActorProfile, 8 AtProtoClient, 9 } from "../../../client.ts"; 10 - import type { RecordResponse } from "@slices/client"; 11 import { recordBlobToCdnUrl } from "@slices/client"; 12 13 /** ··· 59 }; 60 } 61 62 export async function getHydratedWaitlistRequests( 63 client: AtProtoClient, 64 - sliceUri: string 65 - ): Promise<NetworkSlicesWaitlistDefsRequestView[]> { 66 - // Fetch waitlist requests 67 const requestsResponse = 68 await client.network.slices.waitlist.request.getRecords({ 69 - where: { 70 - slice: { eq: sliceUri }, 71 - }, 72 sortBy: [{ field: "createdAt", direction: "desc" }], 73 - limit: 20, 74 }); 75 76 if (!requestsResponse.records || requestsResponse.records.length === 0) { 77 - return []; 78 } 79 80 // Get unique DIDs from requests ··· 124 console.error("Error fetching profiles:", error); 125 } 126 127 - // Transform to RequestView format with profiles 128 - return requestsResponse.records.map((record) => 129 requestToView(record, profilesMap.get(record.did)) 130 ); 131 } 132 133 export async function getHydratedWaitlistInvites( 134 client: AtProtoClient, 135 - sliceUri: string 136 - ): Promise<NetworkSlicesWaitlistDefsInviteView[]> { 137 - // Fetch waitlist invites 138 const invitesResponse = 139 await client.network.slices.waitlist.invite.getRecords({ 140 - where: { 141 - slice: { eq: sliceUri }, 142 - }, 143 sortBy: [{ field: "createdAt", direction: "desc" }], 144 }); 145 146 if (!invitesResponse.records || invitesResponse.records.length === 0) { 147 - return []; 148 } 149 150 // Get unique DIDs from invites ··· 194 console.error("Error fetching profiles:", error); 195 } 196 197 - // Transform to InviteView format with profiles 198 - return invitesResponse.records.map((record) => 199 inviteToView(record, profilesMap.get(record.value.did)) 200 ); 201 } 202 203 export async function createInviteFromRequest(
··· 7 AppBskyActorProfile, 8 AtProtoClient, 9 } from "../../../client.ts"; 10 + import type { RecordResponse, WhereCondition } from "@slices/client"; 11 import { recordBlobToCdnUrl } from "@slices/client"; 12 13 /** ··· 59 }; 60 } 61 62 + async function searchActorsAndProfiles( 63 + client: AtProtoClient, 64 + sliceUri: string, 65 + search: string 66 + ): Promise<string[]> { 67 + if (!search?.trim()) { 68 + return []; 69 + } 70 + 71 + const searchTerm = search.trim(); 72 + const matchingDids = new Set<string>(); 73 + 74 + try { 75 + // Search actors by handle or DID 76 + const actorsResponse = await client.network.slices.slice.getActors({ 77 + slice: sliceUri, 78 + where: { 79 + $or: { 80 + handle: { contains: searchTerm }, 81 + did: { eq: searchTerm }, 82 + }, 83 + }, 84 + }); 85 + 86 + // Add matching actor DIDs 87 + actorsResponse.actors?.forEach((actor) => { 88 + matchingDids.add(actor.did); 89 + }); 90 + 91 + // Search Bluesky profiles by displayName or other fields 92 + const profilesResponse = await client.app.bsky.actor.profile.getRecords({ 93 + orWhere: { 94 + displayName: { contains: searchTerm }, 95 + }, 96 + }); 97 + 98 + // Add matching profile DIDs 99 + profilesResponse.records?.forEach((record) => { 100 + matchingDids.add(record.did); 101 + }); 102 + } catch (error) { 103 + console.error("Error searching actors and profiles:", error); 104 + } 105 + 106 + return Array.from(matchingDids); 107 + } 108 + 109 export async function getHydratedWaitlistRequests( 110 client: AtProtoClient, 111 + sliceUri: string, 112 + cursor?: string, 113 + limit: number = 20, 114 + search?: string 115 + ): Promise<{ 116 + records: NetworkSlicesWaitlistDefsRequestView[]; 117 + cursor?: string; 118 + }> { 119 + // Build where conditions with optional search 120 + const whereConditions: Record<string, WhereCondition> = { 121 + slice: { eq: sliceUri }, 122 + }; 123 + 124 + // Add search condition if provided 125 + if (search && search.trim()) { 126 + const matchingDids = await searchActorsAndProfiles( 127 + client, 128 + sliceUri, 129 + search 130 + ); 131 + if (matchingDids.length > 0) { 132 + whereConditions.did = { in: matchingDids }; 133 + } else { 134 + // If no matching DIDs found, return empty results 135 + return { records: [], cursor: undefined }; 136 + } 137 + } 138 + 139 const requestsResponse = 140 await client.network.slices.waitlist.request.getRecords({ 141 + where: whereConditions, 142 sortBy: [{ field: "createdAt", direction: "desc" }], 143 + limit, 144 + cursor, 145 }); 146 147 if (!requestsResponse.records || requestsResponse.records.length === 0) { 148 + return { records: [], cursor: undefined }; 149 } 150 151 // Get unique DIDs from requests ··· 195 console.error("Error fetching profiles:", error); 196 } 197 198 + const records = requestsResponse.records.map((record) => 199 requestToView(record, profilesMap.get(record.did)) 200 ); 201 + return { records, cursor: requestsResponse.cursor }; 202 } 203 204 export async function getHydratedWaitlistInvites( 205 client: AtProtoClient, 206 + sliceUri: string, 207 + cursor?: string, 208 + limit: number = 20, 209 + search?: string 210 + ): Promise<{ 211 + records: NetworkSlicesWaitlistDefsInviteView[]; 212 + cursor?: string; 213 + }> { 214 + // Build where conditions with optional search 215 + const whereConditions: Record<string, WhereCondition> = { 216 + slice: { eq: sliceUri }, 217 + }; 218 + 219 + // Add search condition if provided 220 + if (search && search.trim()) { 221 + const matchingDids = await searchActorsAndProfiles( 222 + client, 223 + sliceUri, 224 + search 225 + ); 226 + if (matchingDids.length > 0) { 227 + whereConditions.did = { in: matchingDids }; 228 + } else { 229 + // If no matching DIDs found, return empty results 230 + return { records: [], cursor: undefined }; 231 + } 232 + } 233 + 234 const invitesResponse = 235 await client.network.slices.waitlist.invite.getRecords({ 236 + where: whereConditions, 237 sortBy: [{ field: "createdAt", direction: "desc" }], 238 + limit, 239 + cursor, 240 }); 241 242 if (!invitesResponse.records || invitesResponse.records.length === 0) { 243 + return { records: [], cursor: undefined }; 244 } 245 246 // Get unique DIDs from invites ··· 290 console.error("Error fetching profiles:", error); 291 } 292 293 + const records = invitesResponse.records.map((record) => 294 inviteToView(record, profilesMap.get(record.value.did)) 295 ); 296 + return { records, cursor: invitesResponse.cursor }; 297 } 298 299 export async function createInviteFromRequest(
+192 -8
frontend/src/features/slices/waitlist/handlers.tsx
··· 1 import type { Route } from "@std/http/unstable-route"; 2 import { withAuth } from "../../../routes/middleware.ts"; 3 import { withSliceAccess } from "../../../routes/slice-middleware.ts"; 4 - import { extractSliceParams, buildSliceUrlFromView } from "../../../utils/slice-params.ts"; 5 import { getSliceClient } from "../../../utils/client.ts"; 6 import { getRkeyFromUri, buildSliceUri } from "../../../utils/at-uri.ts"; 7 import { renderHTML } from "../../../utils/render.tsx"; 8 import { hxRedirect } from "../../../utils/htmx.ts"; 9 import { SliceWaitlistPage } from "./templates/SliceWaitlistPage.tsx"; 10 import { CreateInviteModal } from "./templates/fragments/CreateInviteModal.tsx"; 11 import type { 12 NetworkSlicesWaitlistDefsRequestView, 13 NetworkSlicesWaitlistDefsInviteView, ··· 57 sliceParams.sliceId 58 ); 59 60 let requests: NetworkSlicesWaitlistDefsRequestView[] = []; 61 let invites: NetworkSlicesWaitlistDefsInviteView[] = []; 62 63 try { 64 - // Fetch hydrated requests with profile information 65 - requests = await getHydratedWaitlistRequests(sliceClient, sliceUri); 66 - 67 - // Fetch hydrated invites with profile information 68 - invites = await getHydratedWaitlistInvites(sliceClient, sliceUri); 69 } catch (error) { 70 console.error("Error fetching waitlist data:", error); 71 - // Continue with empty arrays if fetch fails 72 } 73 74 return renderHTML( ··· 80 currentUser={authContext.currentUser} 81 hasSliceAccess={context.sliceContext?.hasAccess} 82 activeTab={activeTab} 83 /> 84 ); 85 } ··· 246 } 247 } 248 249 async function handleCreateInviteFromRequest( 250 req: Request, 251 params?: URLPatternResult ··· 283 return new Response("DID is required", { status: 400 }); 284 } 285 286 - const sliceUri = buildSliceUri(context.sliceContext.profileDid, sliceParams.sliceId); 287 288 const inviteData: NetworkSlicesWaitlistInvite = { 289 did, ··· 315 pathname: "/profile/:handle/slice/:rkey/waitlist", 316 }), 317 handler: handleSliceWaitlistPage, 318 }, 319 { 320 method: "GET",
··· 1 import type { Route } from "@std/http/unstable-route"; 2 import { withAuth } from "../../../routes/middleware.ts"; 3 import { withSliceAccess } from "../../../routes/slice-middleware.ts"; 4 + import { 5 + extractSliceParams, 6 + buildSliceUrlFromView, 7 + } from "../../../utils/slice-params.ts"; 8 import { getSliceClient } from "../../../utils/client.ts"; 9 import { getRkeyFromUri, buildSliceUri } from "../../../utils/at-uri.ts"; 10 import { renderHTML } from "../../../utils/render.tsx"; 11 import { hxRedirect } from "../../../utils/htmx.ts"; 12 import { SliceWaitlistPage } from "./templates/SliceWaitlistPage.tsx"; 13 import { CreateInviteModal } from "./templates/fragments/CreateInviteModal.tsx"; 14 + import { WaitlistRequestsList } from "./templates/fragments/WaitlistRequestsList.tsx"; 15 + import { WaitlistInvitesList } from "./templates/fragments/WaitlistInvitesList.tsx"; 16 import type { 17 NetworkSlicesWaitlistDefsRequestView, 18 NetworkSlicesWaitlistDefsInviteView, ··· 62 sliceParams.sliceId 63 ); 64 65 + const currentCursor = url.searchParams.get("cursor") || undefined; 66 + const searchQuery = url.searchParams.get("search") || ""; 67 + const limit = 20; 68 + 69 let requests: NetworkSlicesWaitlistDefsRequestView[] = []; 70 let invites: NetworkSlicesWaitlistDefsInviteView[] = []; 71 + let requestsNextCursor: string | undefined; 72 + let invitesNextCursor: string | undefined; 73 74 try { 75 + if (activeTab === "requests") { 76 + const requestsData = await getHydratedWaitlistRequests( 77 + sliceClient, 78 + sliceUri, 79 + currentCursor, 80 + limit, 81 + searchQuery 82 + ); 83 + requests = requestsData.records; 84 + requestsNextCursor = requestsData.cursor; 85 + } else { 86 + const invitesData = await getHydratedWaitlistInvites( 87 + sliceClient, 88 + sliceUri, 89 + currentCursor, 90 + limit, 91 + searchQuery 92 + ); 93 + invites = invitesData.records; 94 + invitesNextCursor = invitesData.cursor; 95 + } 96 } catch (error) { 97 console.error("Error fetching waitlist data:", error); 98 + } 99 + 100 + // Check if this is an HTMX request targeting a specific container (search) 101 + const hxTarget = req.headers.get("hx-target"); 102 + const isContainerTargeted = hxTarget && (hxTarget.includes("-container")); 103 + 104 + if (isContainerTargeted) { 105 + // Return just the container content for search updates 106 + if (activeTab === "requests") { 107 + // Get invites for the requests list (to show "Already invited" status) 108 + const invitesData = await getHydratedWaitlistInvites( 109 + sliceClient, 110 + sliceUri, 111 + undefined, 112 + 1000 113 + ); 114 + const allInvites = invitesData.records; 115 + 116 + return renderHTML( 117 + <WaitlistRequestsList 118 + requests={requests} 119 + invites={allInvites} 120 + slice={context.sliceContext!.slice!} 121 + sliceId={sliceParams.sliceId} 122 + nextCursor={requestsNextCursor} 123 + activeTab={activeTab} 124 + searchQuery={searchQuery} 125 + showWrapper 126 + /> 127 + ); 128 + } else { 129 + return renderHTML( 130 + <WaitlistInvitesList 131 + invites={invites} 132 + slice={context.sliceContext!.slice!} 133 + sliceId={sliceParams.sliceId} 134 + nextCursor={invitesNextCursor} 135 + activeTab={activeTab} 136 + searchQuery={searchQuery} 137 + showWrapper 138 + /> 139 + ); 140 + } 141 } 142 143 return renderHTML( ··· 149 currentUser={authContext.currentUser} 150 hasSliceAccess={context.sliceContext?.hasAccess} 151 activeTab={activeTab} 152 + searchQuery={searchQuery} 153 + requestsNextCursor={requestsNextCursor} 154 + invitesNextCursor={invitesNextCursor} 155 /> 156 ); 157 } ··· 318 } 319 } 320 321 + async function handleWaitlistLoadMore( 322 + req: Request, 323 + params?: URLPatternResult 324 + ): Promise<Response> { 325 + const authContext = await withAuth(req); 326 + const sliceParams = extractSliceParams(params); 327 + 328 + if (!sliceParams) { 329 + return new Response("Invalid slice parameters", { status: 400 }); 330 + } 331 + 332 + const context = await withSliceAccess( 333 + authContext, 334 + sliceParams.handle, 335 + sliceParams.sliceId 336 + ); 337 + 338 + if (!context.sliceContext?.slice || !context.sliceContext?.hasAccess) { 339 + return new Response("Slice not found or access denied", { status: 404 }); 340 + } 341 + 342 + const url = new URL(req.url); 343 + const activeTab = url.searchParams.get("tab") || "requests"; 344 + const currentCursor = url.searchParams.get("cursor") || undefined; 345 + const searchQuery = url.searchParams.get("search") || ""; 346 + const limit = 20; 347 + 348 + const sliceClient = getSliceClient( 349 + authContext, 350 + sliceParams.sliceId, 351 + context.sliceContext.profileDid 352 + ); 353 + 354 + const sliceUri = buildSliceUri( 355 + context.sliceContext.profileDid, 356 + sliceParams.sliceId 357 + ); 358 + 359 + try { 360 + if (activeTab === "requests") { 361 + const requestsData = await getHydratedWaitlistRequests( 362 + sliceClient, 363 + sliceUri, 364 + currentCursor, 365 + limit, 366 + searchQuery 367 + ); 368 + const requests = requestsData.records; 369 + const nextCursor = requestsData.cursor; 370 + 371 + // Get invites for the requests list (to show "Already invited" status) 372 + const invitesData = await getHydratedWaitlistInvites( 373 + sliceClient, 374 + sliceUri, 375 + undefined, 376 + 1000 377 + ); 378 + const invites = invitesData.records; 379 + 380 + // Return just the new records and Load More button (without wrapper) 381 + return renderHTML( 382 + <WaitlistRequestsList 383 + requests={requests} 384 + invites={invites} 385 + slice={context.sliceContext!.slice!} 386 + sliceId={sliceParams.sliceId} 387 + nextCursor={nextCursor} 388 + activeTab={activeTab} 389 + searchQuery={searchQuery} 390 + showWrapper={false} 391 + /> 392 + ); 393 + } else { 394 + const invitesData = await getHydratedWaitlistInvites( 395 + sliceClient, 396 + sliceUri, 397 + currentCursor, 398 + limit, 399 + searchQuery 400 + ); 401 + const invites = invitesData.records; 402 + const nextCursor = invitesData.cursor; 403 + 404 + // Return just the new records and Load More button (without wrapper) 405 + return renderHTML( 406 + <WaitlistInvitesList 407 + invites={invites} 408 + slice={context.sliceContext!.slice!} 409 + sliceId={sliceParams.sliceId} 410 + nextCursor={nextCursor} 411 + activeTab={activeTab} 412 + searchQuery={searchQuery} 413 + showWrapper={false} 414 + /> 415 + ); 416 + } 417 + } catch (error) { 418 + console.error("Error fetching waitlist data:", error); 419 + return new Response("Error loading more items", { status: 500 }); 420 + } 421 + } 422 + 423 async function handleCreateInviteFromRequest( 424 req: Request, 425 params?: URLPatternResult ··· 457 return new Response("DID is required", { status: 400 }); 458 } 459 460 + const sliceUri = buildSliceUri( 461 + context.sliceContext.profileDid, 462 + sliceParams.sliceId 463 + ); 464 465 const inviteData: NetworkSlicesWaitlistInvite = { 466 did, ··· 492 pathname: "/profile/:handle/slice/:rkey/waitlist", 493 }), 494 handler: handleSliceWaitlistPage, 495 + }, 496 + { 497 + method: "GET", 498 + pattern: new URLPattern({ 499 + pathname: "/profile/:handle/slice/:rkey/waitlist/load-more", 500 + }), 501 + handler: handleWaitlistLoadMore, 502 }, 503 { 504 method: "GET",
+37 -13
frontend/src/features/slices/waitlist/templates/SliceWaitlistPage.tsx
··· 1 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 2 import { WaitlistRequestsList } from "./fragments/WaitlistRequestsList.tsx"; 3 import { WaitlistInvitesList } from "./fragments/WaitlistInvitesList.tsx"; 4 import { Button } from "../../../../shared/fragments/Button.tsx"; 5 import { Tabs } from "../../../../shared/fragments/Tabs.tsx"; 6 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; ··· 19 currentUser?: AuthenticatedUser; 20 hasSliceAccess?: boolean; 21 activeTab?: string; 22 } 23 24 export function SliceWaitlistPage({ ··· 29 currentUser, 30 hasSliceAccess, 31 activeTab = "requests", 32 }: SliceWaitlistPageProps) { 33 return ( 34 <SlicePage ··· 55 <Tabs.List> 56 <Tabs.Tab 57 active={activeTab === "requests"} 58 - count={requests.length} 59 hxGet={buildSliceUrlFromView(slice, sliceId, "waitlist?tab=requests")} 60 hxTarget="body" 61 > ··· 63 </Tabs.Tab> 64 <Tabs.Tab 65 active={activeTab === "invites"} 66 - count={invites.length} 67 hxGet={buildSliceUrlFromView(slice, sliceId, "waitlist?tab=invites")} 68 hxTarget="body" 69 > ··· 71 </Tabs.Tab> 72 </Tabs.List> 73 74 <Tabs.Content active={activeTab === "requests"}> 75 - <WaitlistRequestsList 76 - requests={requests} 77 - invites={invites} 78 - slice={slice} 79 - sliceId={sliceId} 80 - /> 81 </Tabs.Content> 82 83 <Tabs.Content active={activeTab === "invites"}> 84 - <WaitlistInvitesList 85 - invites={invites} 86 - slice={slice} 87 - sliceId={sliceId} 88 - /> 89 </Tabs.Content> 90 </Tabs> 91 </div>
··· 1 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 2 import { WaitlistRequestsList } from "./fragments/WaitlistRequestsList.tsx"; 3 import { WaitlistInvitesList } from "./fragments/WaitlistInvitesList.tsx"; 4 + import { WaitlistSearch } from "./fragments/WaitlistSearch.tsx"; 5 import { Button } from "../../../../shared/fragments/Button.tsx"; 6 import { Tabs } from "../../../../shared/fragments/Tabs.tsx"; 7 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; ··· 20 currentUser?: AuthenticatedUser; 21 hasSliceAccess?: boolean; 22 activeTab?: string; 23 + searchQuery?: string; 24 + requestsNextCursor?: string; 25 + invitesNextCursor?: string; 26 } 27 28 export function SliceWaitlistPage({ ··· 33 currentUser, 34 hasSliceAccess, 35 activeTab = "requests", 36 + searchQuery = "", 37 + requestsNextCursor, 38 + invitesNextCursor, 39 }: SliceWaitlistPageProps) { 40 return ( 41 <SlicePage ··· 62 <Tabs.List> 63 <Tabs.Tab 64 active={activeTab === "requests"} 65 + count={slice.waitlistRequestCount} 66 hxGet={buildSliceUrlFromView(slice, sliceId, "waitlist?tab=requests")} 67 hxTarget="body" 68 > ··· 70 </Tabs.Tab> 71 <Tabs.Tab 72 active={activeTab === "invites"} 73 + count={slice.waitlistInviteCount} 74 hxGet={buildSliceUrlFromView(slice, sliceId, "waitlist?tab=invites")} 75 hxTarget="body" 76 > ··· 78 </Tabs.Tab> 79 </Tabs.List> 80 81 + <WaitlistSearch 82 + search={searchQuery} 83 + sliceId={sliceId} 84 + slice={slice} 85 + activeTab={activeTab} 86 + /> 87 + 88 <Tabs.Content active={activeTab === "requests"}> 89 + <div id="requests-container"> 90 + <WaitlistRequestsList 91 + requests={requests} 92 + invites={invites} 93 + slice={slice} 94 + sliceId={sliceId} 95 + nextCursor={requestsNextCursor} 96 + activeTab={activeTab} 97 + searchQuery={searchQuery} 98 + /> 99 + </div> 100 </Tabs.Content> 101 102 <Tabs.Content active={activeTab === "invites"}> 103 + <div id="invites-container"> 104 + <WaitlistInvitesList 105 + invites={invites} 106 + slice={slice} 107 + sliceId={sliceId} 108 + nextCursor={invitesNextCursor} 109 + activeTab={activeTab} 110 + searchQuery={searchQuery} 111 + /> 112 + </div> 113 </Tabs.Content> 114 </Tabs> 115 </div>
+27 -4
frontend/src/features/slices/waitlist/templates/fragments/WaitlistInvitesList.tsx
··· 3 import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 import { EmptyState } from "../../../../../shared/fragments/EmptyState.tsx"; 5 import { ActorAvatar } from "../../../../../shared/fragments/ActorAvatar.tsx"; 6 import { buildSliceUrlFromView } from "../../../../../utils/slice-params.ts"; 7 import { timeAgo } from "../../../../../utils/time.ts"; 8 import { UserCheck } from "lucide-preact"; ··· 15 invites: NetworkSlicesWaitlistDefsInviteView[]; 16 slice: NetworkSlicesSliceDefsSliceView; 17 sliceId: string; 18 } 19 20 export function WaitlistInvitesList({ 21 invites, 22 slice, 23 sliceId, 24 }: WaitlistInvitesListProps) { 25 const isExpired = (invite: NetworkSlicesWaitlistDefsInviteView) => { 26 if (!invite.expiresAt) return false; ··· 38 ); 39 } 40 41 - return ( 42 - <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 43 {invites.map((invite, index) => ( 44 - <ListItem key={`invite-${index}`}> 45 <div className="flex items-center justify-between w-full px-6 py-4"> 46 <div className="flex items-center gap-3 flex-1 min-w-0"> 47 <ActorAvatar ··· 88 </div> 89 </ListItem> 90 ))} 91 - </div> 92 ); 93 }
··· 3 import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 import { EmptyState } from "../../../../../shared/fragments/EmptyState.tsx"; 5 import { ActorAvatar } from "../../../../../shared/fragments/ActorAvatar.tsx"; 6 + import { LoadMore } from "../../../../../shared/fragments/LoadMore.tsx"; 7 import { buildSliceUrlFromView } from "../../../../../utils/slice-params.ts"; 8 import { timeAgo } from "../../../../../utils/time.ts"; 9 import { UserCheck } from "lucide-preact"; ··· 16 invites: NetworkSlicesWaitlistDefsInviteView[]; 17 slice: NetworkSlicesSliceDefsSliceView; 18 sliceId: string; 19 + nextCursor?: string; 20 + activeTab: string; 21 + searchQuery?: string; 22 + showWrapper?: boolean; 23 } 24 25 export function WaitlistInvitesList({ 26 invites, 27 slice, 28 sliceId, 29 + nextCursor, 30 + activeTab, 31 + searchQuery = "", 32 + showWrapper = true, 33 }: WaitlistInvitesListProps) { 34 const isExpired = (invite: NetworkSlicesWaitlistDefsInviteView) => { 35 if (!invite.expiresAt) return false; ··· 47 ); 48 } 49 50 + const baseUrl = buildSliceUrlFromView(slice, sliceId, `waitlist?tab=${activeTab}`); 51 + const nextUrl = nextCursor ? `${baseUrl}&cursor=${nextCursor}${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}` : undefined; 52 + 53 + const content = ( 54 + <> 55 {invites.map((invite, index) => ( 56 + <ListItem key={`invite-${index}`} data-record-item> 57 <div className="flex items-center justify-between w-full px-6 py-4"> 58 <div className="flex items-center gap-3 flex-1 min-w-0"> 59 <ActorAvatar ··· 100 </div> 101 </ListItem> 102 ))} 103 + <LoadMore nextUrl={nextUrl} /> 104 + </> 105 ); 106 + 107 + if (showWrapper) { 108 + return ( 109 + <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 110 + {content} 111 + </div> 112 + ); 113 + } 114 + 115 + return content; 116 }
+27 -4
frontend/src/features/slices/waitlist/templates/fragments/WaitlistRequestsList.tsx
··· 3 import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 import { EmptyState } from "../../../../../shared/fragments/EmptyState.tsx"; 5 import { ActorAvatar } from "../../../../../shared/fragments/ActorAvatar.tsx"; 6 import { Users } from "lucide-preact"; 7 import type { 8 NetworkSlicesWaitlistDefsRequestView, ··· 17 invites: NetworkSlicesWaitlistDefsInviteView[]; 18 slice: NetworkSlicesSliceDefsSliceView; 19 sliceId: string; 20 } 21 22 export function WaitlistRequestsList({ ··· 24 invites, 25 slice, 26 sliceId, 27 }: WaitlistRequestsListProps) { 28 if (requests.length === 0) { 29 return ( ··· 36 ); 37 } 38 39 - return ( 40 - <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 41 {requests.map((request, index) => { 42 // Check if this DID already has an invite 43 const requestDid = request.profile?.did || "unknown"; 44 const hasInvite = invites.some(invite => invite.did === requestDid); 45 46 return ( 47 - <ListItem key={`request-${index}`}> 48 <div className="flex items-center justify-between w-full px-6 py-4"> 49 <div className="flex items-center gap-3 flex-1 min-w-0"> 50 <ActorAvatar ··· 87 </ListItem> 88 ); 89 })} 90 - </div> 91 ); 92 }
··· 3 import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 import { EmptyState } from "../../../../../shared/fragments/EmptyState.tsx"; 5 import { ActorAvatar } from "../../../../../shared/fragments/ActorAvatar.tsx"; 6 + import { LoadMore } from "../../../../../shared/fragments/LoadMore.tsx"; 7 import { Users } from "lucide-preact"; 8 import type { 9 NetworkSlicesWaitlistDefsRequestView, ··· 18 invites: NetworkSlicesWaitlistDefsInviteView[]; 19 slice: NetworkSlicesSliceDefsSliceView; 20 sliceId: string; 21 + nextCursor?: string; 22 + activeTab: string; 23 + searchQuery?: string; 24 + showWrapper?: boolean; 25 } 26 27 export function WaitlistRequestsList({ ··· 29 invites, 30 slice, 31 sliceId, 32 + nextCursor, 33 + activeTab, 34 + searchQuery = "", 35 + showWrapper = true, 36 }: WaitlistRequestsListProps) { 37 if (requests.length === 0) { 38 return ( ··· 45 ); 46 } 47 48 + const baseUrl = buildSliceUrlFromView(slice, sliceId, `waitlist?tab=${activeTab}`); 49 + const nextUrl = nextCursor ? `${baseUrl}&cursor=${nextCursor}${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}` : undefined; 50 + 51 + const content = ( 52 + <> 53 {requests.map((request, index) => { 54 // Check if this DID already has an invite 55 const requestDid = request.profile?.did || "unknown"; 56 const hasInvite = invites.some(invite => invite.did === requestDid); 57 58 return ( 59 + <ListItem key={`request-${index}`} data-record-item> 60 <div className="flex items-center justify-between w-full px-6 py-4"> 61 <div className="flex items-center gap-3 flex-1 min-w-0"> 62 <ActorAvatar ··· 99 </ListItem> 100 ); 101 })} 102 + <LoadMore nextUrl={nextUrl} /> 103 + </> 104 ); 105 + 106 + if (showWrapper) { 107 + return ( 108 + <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 109 + {content} 110 + </div> 111 + ); 112 + } 113 + 114 + return content; 115 }
+38
frontend/src/features/slices/waitlist/templates/fragments/WaitlistSearch.tsx
···
··· 1 + import { Input } from "../../../../../shared/fragments/Input.tsx"; 2 + import { buildSliceUrlFromView } from "../../../../../utils/slice-params.ts"; 3 + import type { NetworkSlicesSliceDefsSliceView } from "../../../../../client.ts"; 4 + 5 + interface WaitlistSearchProps { 6 + search: string; 7 + sliceId: string; 8 + slice: NetworkSlicesSliceDefsSliceView; 9 + activeTab: string; 10 + } 11 + 12 + export function WaitlistSearch({ 13 + search, 14 + sliceId, 15 + slice, 16 + activeTab, 17 + }: WaitlistSearchProps) { 18 + const waitlistUrl = buildSliceUrlFromView( 19 + slice, 20 + sliceId, 21 + `waitlist?tab=${activeTab}` 22 + ); 23 + 24 + return ( 25 + <div className="pt-4 pb-2"> 26 + <Input 27 + type="text" 28 + name="search" 29 + value={search} 30 + placeholder="Search by DID, handle, or display name" 31 + hx-get={waitlistUrl} 32 + hx-trigger="input changed delay:300ms, search" 33 + hx-target={`#${activeTab}-container`} 34 + hx-swap="innerHTML" 35 + /> 36 + </div> 37 + ); 38 + }
+16 -10
frontend/src/features/waitlist/handlers.tsx
··· 1 import type { Route } from "@std/http/unstable-route"; 2 import { withAuth } from "../../routes/middleware.ts"; 3 import { renderHTML } from "../../utils/render.tsx"; 4 import { WaitlistPage } from "./templates/WaitlistPage.tsx"; ··· 15 const error = url.searchParams.get("error"); 16 17 // Fetch recent waitlist requests to show avatars for social proof 18 - let recentRequests; 19 let totalWaitlistCount = 0; 20 if (SLICE_URI) { 21 try { 22 // Get total count of waitlist requests 23 - const countResponse = await publicClient.network.slices.waitlist.request.countRecords({ 24 - where: { 25 - slice: { eq: SLICE_URI }, 26 - }, 27 - }); 28 totalWaitlistCount = countResponse.count; 29 30 - recentRequests = await getHydratedWaitlistRequests(publicClient, SLICE_URI); 31 - // Limit to most recent 10 and reverse to show newest first 32 - recentRequests = recentRequests.slice(0, 10); 33 } catch (error) { 34 console.error("Failed to fetch recent waitlist requests:", error); 35 // Continue without recent requests if fetch fails ··· 54 pattern: new URLPattern({ pathname: "/waitlist" }), 55 handler: handleWaitlistPage, 56 }, 57 - ];
··· 1 import type { Route } from "@std/http/unstable-route"; 2 + import type { NetworkSlicesWaitlistDefsRequestView } from "../../client.ts"; 3 import { withAuth } from "../../routes/middleware.ts"; 4 import { renderHTML } from "../../utils/render.tsx"; 5 import { WaitlistPage } from "./templates/WaitlistPage.tsx"; ··· 16 const error = url.searchParams.get("error"); 17 18 // Fetch recent waitlist requests to show avatars for social proof 19 + let recentRequests: NetworkSlicesWaitlistDefsRequestView[] = []; 20 let totalWaitlistCount = 0; 21 if (SLICE_URI) { 22 try { 23 // Get total count of waitlist requests 24 + const countResponse = 25 + await publicClient.network.slices.waitlist.request.countRecords({ 26 + where: { 27 + slice: { eq: SLICE_URI }, 28 + }, 29 + }); 30 totalWaitlistCount = countResponse.count; 31 32 + const hydratedResponse = await getHydratedWaitlistRequests( 33 + publicClient, 34 + SLICE_URI, 35 + undefined, 36 + 10 37 + ); 38 + recentRequests = hydratedResponse.records; 39 } catch (error) { 40 console.error("Failed to fetch recent waitlist requests:", error); 41 // Continue without recent requests if fetch fails ··· 60 pattern: new URLPattern({ pathname: "/waitlist" }), 61 handler: handleWaitlistPage, 62 }, 63 + ];
+16 -9
frontend/src/features/waitlist/templates/fragments/WaitlistForm.tsx
··· 12 totalWaitlistCount?: number; 13 } 14 15 - export function WaitlistForm({ error, recentRequests, totalWaitlistCount }: WaitlistFormProps) { 16 const getErrorMessage = (error: string) => { 17 switch (error) { 18 case "oauth_not_configured": ··· 36 37 return ( 38 <Card padding="md"> 39 - {recentRequests && recentRequests.length > 0 && totalWaitlistCount && totalWaitlistCount > 0 && ( 40 - <div className="mb-6 text-center"> 41 - <Text as="p" size="sm" variant="muted" className="mb-3"> 42 - Join {totalWaitlistCount.toLocaleString()} others who are waiting 43 - </Text> 44 - <AvatarStack requests={recentRequests} maxDisplay={20} size={24} /> 45 - </div> 46 - )} 47 48 <form action="/auth/waitlist/initiate" method="POST"> 49 <div className="space-y-6">
··· 12 totalWaitlistCount?: number; 13 } 14 15 + export function WaitlistForm({ 16 + error, 17 + recentRequests, 18 + totalWaitlistCount, 19 + }: WaitlistFormProps) { 20 const getErrorMessage = (error: string) => { 21 switch (error) { 22 case "oauth_not_configured": ··· 40 41 return ( 42 <Card padding="md"> 43 + {recentRequests && 44 + recentRequests.length > 0 && 45 + totalWaitlistCount && 46 + totalWaitlistCount > 0 && ( 47 + <div className="mb-6 text-center"> 48 + <Text as="p" size="sm" variant="muted" className="mb-3"> 49 + Join {totalWaitlistCount.toLocaleString()} others who are waiting 50 + </Text> 51 + <AvatarStack requests={recentRequests} maxDisplay={20} size={24} /> 52 + </div> 53 + )} 54 55 <form action="/auth/waitlist/initiate" method="POST"> 56 <div className="space-y-6">
+17 -5
frontend/src/lib/api.ts
··· 75 const creatorProfile = await getSliceActor(client, sliceRecord.did); 76 if (!creatorProfile) return null; 77 78 - const sparklinesMap = await fetchSparklinesForSlices(client, [uri]); 79 - const sparklineData = sparklinesMap[uri]; 80 81 - const stats = await fetchStatsForSlice(client, uri); 82 83 - return sliceToView(sliceRecord, creatorProfile, sparklineData, stats); 84 } catch (error) { 85 console.error("Failed to get slice:", error); 86 return null; ··· 120 sliceRecord: RecordResponse<NetworkSlicesSlice>, 121 creator: NetworkSlicesActorDefsProfileViewBasic, 122 sparkline?: NetworkSlicesSliceDefsSparklinePoint[], 123 - stats?: NetworkSlicesSliceStatsOutput | null 124 ): NetworkSlicesSliceDefsSliceView { 125 return { 126 uri: sliceRecord.uri, ··· 133 indexedRecordCount: stats?.totalRecords || 0, 134 indexedActorCount: stats?.totalActors || 0, 135 indexedCollectionCount: stats?.collectionStats.length || 0, 136 }; 137 } 138
··· 75 const creatorProfile = await getSliceActor(client, sliceRecord.did); 76 if (!creatorProfile) return null; 77 78 + const [sparklinesMap, stats, requestCount, inviteCount] = await Promise.all([ 79 + fetchSparklinesForSlices(client, [uri]), 80 + fetchStatsForSlice(client, uri), 81 + client.network.slices.waitlist.request.countRecords({ 82 + where: { slice: { eq: uri } }, 83 + }).then(r => r.count).catch(() => 0), 84 + client.network.slices.waitlist.invite.countRecords({ 85 + where: { slice: { eq: uri } }, 86 + }).then(r => r.count).catch(() => 0), 87 + ]); 88 89 + const sparklineData = sparklinesMap[uri]; 90 91 + return sliceToView(sliceRecord, creatorProfile, sparklineData, stats, requestCount, inviteCount); 92 } catch (error) { 93 console.error("Failed to get slice:", error); 94 return null; ··· 128 sliceRecord: RecordResponse<NetworkSlicesSlice>, 129 creator: NetworkSlicesActorDefsProfileViewBasic, 130 sparkline?: NetworkSlicesSliceDefsSparklinePoint[], 131 + stats?: NetworkSlicesSliceStatsOutput | null, 132 + waitlistRequestCount?: number, 133 + waitlistInviteCount?: number 134 ): NetworkSlicesSliceDefsSliceView { 135 return { 136 uri: sliceRecord.uri, ··· 143 indexedRecordCount: stats?.totalRecords || 0, 144 indexedActorCount: stats?.totalActors || 0, 145 indexedCollectionCount: stats?.collectionStats.length || 0, 146 + waitlistRequestCount: waitlistRequestCount || 0, 147 + waitlistInviteCount: waitlistInviteCount || 0, 148 }; 149 } 150
+29
frontend/src/shared/fragments/LoadMore.tsx
···
··· 1 + import type { JSX } from "preact"; 2 + import { Button } from "./Button.tsx"; 3 + 4 + interface LoadMoreProps { 5 + nextUrl?: string; 6 + loading?: boolean; 7 + } 8 + 9 + export function LoadMore({ nextUrl, loading = false }: LoadMoreProps): JSX.Element | null { 10 + if (!nextUrl) { 11 + return null; 12 + } 13 + 14 + return ( 15 + <div className="load-more-container text-center py-4 border-t border-zinc-200 dark:border-zinc-700"> 16 + <Button 17 + variant="outline" 18 + size="md" 19 + disabled={loading} 20 + hx-get={nextUrl.replace("/waitlist?", "/waitlist/load-more?")} 21 + hx-target="closest .load-more-container" 22 + hx-swap="outerHTML" 23 + hx-on="htmx:beforeRequest: this.disabled = true; this.textContent = 'Loading...';" 24 + > 25 + {loading ? "Loading..." : "Load More"} 26 + </Button> 27 + </div> 28 + ); 29 + }
+1 -1
frontend/src/shared/fragments/Tabs.tsx
··· 127 }: TabsContentProps): JSX.Element | null { 128 if (!active) return null; 129 130 - return <div className={cn("py-6", className)}>{children}</div>; 131 };
··· 127 }: TabsContentProps): JSX.Element | null { 128 if (!active) return null; 129 130 + return <div className={cn("py-2", className)}>{children}</div>; 131 };
+8
lexicons/network/slices/slice/defs.json
··· 50 "indexedCollectionCount": { 51 "type": "integer", 52 "description": "Number of collections with indexed records" 53 } 54 } 55 },
··· 50 "indexedCollectionCount": { 51 "type": "integer", 52 "description": "Number of collections with indexed records" 53 + }, 54 + "waitlistRequestCount": { 55 + "type": "integer", 56 + "description": "Total number of waitlist requests for this slice" 57 + }, 58 + "waitlistInviteCount": { 59 + "type": "integer", 60 + "description": "Total number of waitlist invites for this slice" 61 } 62 } 63 },
+5 -1
packages/cli/src/generated_client.ts
··· 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-26 18:21:58 UTC 3 // Lexicons: 40 4 5 /** ··· 984 indexedActorCount?: number; 985 /** Number of collections with indexed records */ 986 indexedCollectionCount?: number; 987 } 988 989 export interface NetworkSlicesSliceDefsSparklinePoint {
··· 1 // Generated TypeScript client for AT Protocol records 2 + // Generated at: 2025-09-28 00:41:51 UTC 3 // Lexicons: 40 4 5 /** ··· 984 indexedActorCount?: number; 985 /** Number of collections with indexed records */ 986 indexedCollectionCount?: number; 987 + /** Total number of waitlist requests for this slice */ 988 + waitlistRequestCount?: number; 989 + /** Total number of waitlist invites for this slice */ 990 + waitlistInviteCount?: number; 991 } 992 993 export interface NetworkSlicesSliceDefsSparklinePoint {