Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 405 lines 11 kB view raw
1const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3100'; 2 3export interface ProfileSearchResult { 4 did?: string; 5 handle: string; 6 displayName?: string; 7 headline?: string; 8 avatar?: string; 9 about?: string; 10 currentRole?: string; 11 currentCompany?: string; 12 claimed?: boolean; 13} 14 15export async function fetchProfile(handleOrDid: string) { 16 const maxRetries = 3; 17 for (let attempt = 0; attempt <= maxRetries; attempt++) { 18 const res = await fetch(`${API_URL}/api/profile/${encodeURIComponent(handleOrDid)}`, { 19 next: { revalidate: 300, tags: [`profile-${handleOrDid}`] }, 20 signal: AbortSignal.timeout(10000), 21 }); 22 if (res.status === 429 && attempt < maxRetries) { 23 const retryAfter = Math.min(parseInt(res.headers.get('retry-after') ?? '2', 10), 3); 24 await new Promise((r) => setTimeout(r, retryAfter * 1000)); 25 continue; 26 } 27 if (!res.ok) return null; 28 return res.json(); 29 } 30 return null; 31} 32 33export async function searchProfiles(query: string): Promise<ProfileSearchResult[]> { 34 if (!query.trim()) return []; 35 const res = await fetch(`${API_URL}/api/search/profiles?q=${encodeURIComponent(query)}`, { 36 cache: 'no-store', 37 }); 38 if (!res.ok) return []; 39 const data = await res.json(); 40 return data.profiles ?? []; 41} 42 43// --- Suggestions --- 44 45export interface SuggestionProfile { 46 did: string; 47 handle: string; 48 displayName?: string; 49 headline?: string; 50 avatarUrl?: string; 51 source: string; 52 dismissed: boolean; 53} 54 55export interface SuggestionsResponse { 56 onSifa: SuggestionProfile[]; 57 notOnSifa: SuggestionProfile[]; 58 cursor?: string; 59} 60 61export async function fetchSuggestions(opts?: { 62 source?: string; 63 includeDismissed?: boolean; 64 cursor?: string; 65 limit?: number; 66}): Promise<SuggestionsResponse> { 67 const params = new URLSearchParams(); 68 if (opts?.source) params.set('source', opts.source); 69 if (opts?.includeDismissed) params.set('include_dismissed', 'true'); 70 if (opts?.cursor) params.set('cursor', opts.cursor); 71 if (opts?.limit) params.set('limit', String(opts.limit)); 72 73 const qs = params.toString(); 74 const res = await fetch(`${API_URL}/api/suggestions${qs ? `?${qs}` : ''}`, { 75 credentials: 'include', 76 cache: 'no-store', 77 }); 78 if (!res.ok) return { onSifa: [], notOnSifa: [] }; 79 return res.json(); 80} 81 82export async function fetchSuggestionCount(since?: string): Promise<number> { 83 const params = since ? `?since=${encodeURIComponent(since)}` : ''; 84 const res = await fetch(`${API_URL}/api/suggestions/count${params}`, { 85 credentials: 'include', 86 cache: 'no-store', 87 }); 88 if (!res.ok) return 0; 89 const data = await res.json(); 90 return data.count ?? 0; 91} 92 93export async function syncSuggestions(): Promise<{ 94 imported: { bluesky: number; tangled: number }; 95}> { 96 const res = await fetch(`${API_URL}/api/suggestions/sync`, { 97 method: 'POST', 98 credentials: 'include', 99 }); 100 if (!res.ok) throw new Error('Sync failed'); 101 return res.json(); 102} 103 104export async function dismissSuggestion(subjectDid: string): Promise<void> { 105 await fetch(`${API_URL}/api/suggestions/dismiss`, { 106 method: 'POST', 107 headers: { 'Content-Type': 'application/json' }, 108 credentials: 'include', 109 body: JSON.stringify({ subjectDid }), 110 }); 111} 112 113export async function undismissSuggestion(subjectDid: string): Promise<void> { 114 await fetch(`${API_URL}/api/suggestions/dismiss/${encodeURIComponent(subjectDid)}`, { 115 method: 'DELETE', 116 credentials: 'include', 117 }); 118} 119 120export async function createInvite(subjectDid: string): Promise<string> { 121 const res = await fetch(`${API_URL}/api/invites`, { 122 method: 'POST', 123 headers: { 'Content-Type': 'application/json' }, 124 credentials: 'include', 125 body: JSON.stringify({ subjectDid }), 126 }); 127 if (!res.ok) throw new Error('Failed to create invite'); 128 const data = await res.json(); 129 return data.inviteUrl; 130} 131 132export interface StatsResponse { 133 profileCount: number; 134 avatars: string[]; 135 atproto: { 136 userCount: number; 137 growthPerSecond: number; 138 timestamp: number; 139 } | null; 140} 141 142export async function fetchStats(): Promise<StatsResponse | null> { 143 try { 144 const res = await fetch(`${API_URL}/api/stats`, { 145 next: { revalidate: 900 }, 146 }); 147 if (!res.ok) return null; 148 return res.json(); 149 } catch { 150 return null; 151 } 152} 153 154export interface FeaturedProfile { 155 did: string; 156 handle: string; 157 displayName?: string; 158 avatar?: string; 159 pronouns?: string; 160 headline?: string; 161 about?: string; 162 currentRole?: string; 163 currentCompany?: string; 164 locationCountry?: string; 165 locationRegion?: string; 166 locationCity?: string; 167 countryCode?: string; 168 location?: string; 169 website?: string; 170 openTo?: string[]; 171 preferredWorkplace?: string[]; 172 followersCount?: number; 173 atprotoFollowersCount?: number; 174 pdsProvider?: { name: string; host: string } | null; 175 claimed: boolean; 176 featuredDate: string; 177} 178 179export async function fetchFeaturedProfile(): Promise<FeaturedProfile | null> { 180 try { 181 const res = await fetch(`${API_URL}/api/featured-profile`, { 182 next: { revalidate: 900 }, 183 }); 184 if (res.status === 204 || !res.ok) return null; 185 return res.json(); 186 } catch { 187 return null; 188 } 189} 190 191// --- Following (My Network) --- 192 193export interface FollowProfile { 194 did: string; 195 handle: string; 196 displayName?: string; 197 headline?: string; 198 avatarUrl?: string; 199 source: string; 200 claimed: boolean; 201 followedAt: string; 202} 203 204export interface FollowingResponse { 205 follows: FollowProfile[]; 206 cursor?: string; 207} 208 209export async function fetchFollowing(opts?: { 210 source?: string; 211 cursor?: string; 212 limit?: number; 213}): Promise<FollowingResponse> { 214 const params = new URLSearchParams(); 215 if (opts?.source) params.set('source', opts.source); 216 if (opts?.cursor) params.set('cursor', opts.cursor); 217 if (opts?.limit) params.set('limit', String(opts.limit)); 218 219 const qs = params.toString(); 220 const res = await fetch(`${API_URL}/api/following${qs ? `?${qs}` : ''}`, { 221 credentials: 'include', 222 cache: 'no-store', 223 }); 224 if (!res.ok) return { follows: [] }; 225 return res.json(); 226} 227 228export async function followUser(subjectDid: string): Promise<boolean> { 229 const res = await fetch(`${API_URL}/api/follow`, { 230 method: 'POST', 231 headers: { 'Content-Type': 'application/json' }, 232 credentials: 'include', 233 body: JSON.stringify({ subjectDid }), 234 }); 235 return res.ok; 236} 237 238export async function unfollowUser(subjectDid: string): Promise<boolean> { 239 const res = await fetch(`${API_URL}/api/follow/${encodeURIComponent(subjectDid)}`, { 240 method: 'DELETE', 241 credentials: 'include', 242 }); 243 return res.ok; 244} 245 246// --- Apps Registry --- 247 248export interface AppRegistryEntry { 249 id: string; 250 name: string; 251 category: string; 252 collectionPrefixes: string[]; 253 scanCollections: string[]; 254 urlPattern?: string; 255 color: string; 256} 257 258export async function fetchAppsRegistry(): Promise<AppRegistryEntry[]> { 259 try { 260 const res = await fetch(`${API_URL}/api/apps/registry`, { 261 next: { revalidate: 86400 }, 262 }); 263 if (!res.ok) return []; 264 return res.json(); 265 } catch { 266 return []; 267 } 268} 269 270// --- Privacy / GDPR --- 271 272export async function requestProfileRemoval(handleOrDid: string): Promise<boolean> { 273 try { 274 const res = await fetch(`${API_URL}/api/privacy/suppress`, { 275 method: 'POST', 276 headers: { 'Content-Type': 'application/json' }, 277 body: JSON.stringify({ handleOrDid }), 278 }); 279 return res.ok; 280 } catch { 281 return false; 282 } 283} 284 285// --- Activity Heatmap --- 286 287export interface HeatmapDay { 288 date: string; 289 total: number; 290 apps: { appId: string; count: number }[]; 291} 292 293export interface HeatmapResponse { 294 days: HeatmapDay[]; 295 appTotals: { appId: string; appName: string; total: number }[]; 296 thresholds: [number, number, number, number]; 297} 298 299export async function fetchHeatmapData( 300 handleOrDid: string, 301 days: number, 302): Promise<HeatmapResponse | null> { 303 try { 304 const res = await fetch( 305 `${API_URL}/api/activity/${encodeURIComponent(handleOrDid)}/heatmap?days=${days}`, 306 { 307 next: { revalidate: 900, tags: [`heatmap-${handleOrDid}`] }, 308 }, 309 ); 310 if (!res.ok) return null; 311 return (await res.json()) as HeatmapResponse; 312 } catch { 313 return null; 314 } 315} 316 317// --- Activity Teaser --- 318 319export interface ActivityItem { 320 uri: string; 321 collection: string; 322 rkey: string; 323 record: Record<string, unknown>; 324 appId: string; 325 appName: string; 326 category: string; 327 indexedAt: string; 328} 329 330export interface ActivityTeaserResponse { 331 items: ActivityItem[]; 332} 333 334export async function fetchActivityTeaser( 335 handleOrDid: string, 336): Promise<ActivityTeaserResponse | null> { 337 try { 338 const res = await fetch(`${API_URL}/api/activity/${encodeURIComponent(handleOrDid)}/teaser`, { 339 next: { revalidate: 300, tags: [`activity-teaser-${handleOrDid}`] }, 340 }); 341 if (!res.ok) return null; 342 return res.json(); 343 } catch { 344 return null; 345 } 346} 347 348// --- Activity Feed --- 349 350export interface ActivityFeedResponse { 351 items: ActivityItem[]; 352 cursor: string | null; 353 hasMore: boolean; 354 availableCategories?: string[]; 355} 356 357export async function fetchActivityFeed( 358 handleOrDid: string, 359 opts?: { category?: string; limit?: number; cursor?: string }, 360): Promise<ActivityFeedResponse | null> { 361 try { 362 const params = new URLSearchParams(); 363 if (opts?.category) params.set('category', opts.category); 364 if (opts?.limit) params.set('limit', String(opts.limit)); 365 if (opts?.cursor) params.set('cursor', opts.cursor); 366 const qs = params.toString(); 367 const res = await fetch( 368 `${API_URL}/api/activity/${encodeURIComponent(handleOrDid)}${qs ? `?${qs}` : ''}`, 369 { cache: 'no-store' }, 370 ); 371 if (!res.ok) return null; 372 return res.json(); 373 } catch { 374 return null; 375 } 376} 377 378// --- Activity Visibility --- 379 380export async function updateActivityVisibility(appId: string, visible: boolean): Promise<boolean> { 381 const res = await fetch(`${API_URL}/api/profile/activity-visibility`, { 382 method: 'PUT', 383 headers: { 'Content-Type': 'application/json' }, 384 credentials: 'include', 385 body: JSON.stringify({ appId, visible }), 386 }); 387 return res.ok; 388} 389 390export interface MeetingEntry { 391 subjectDid: string; 392 meetingToken: string; 393 createdAt: string; 394 note: string | null; 395 eventContext: Array<{ slug: string; name: string; bothRsvped: boolean }>; 396} 397 398export async function fetchMeetings(): Promise<MeetingEntry[]> { 399 const res = await fetch(`${API_URL}/api/meet/list`, { 400 credentials: 'include', 401 }); 402 if (!res.ok) return []; 403 const data = (await res.json()) as { meetings: MeetingEntry[] }; 404 return data.meetings; 405}