ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

cleanup types, constants, utils

authored by byarielm.fyi and committed by byarielm.fyi 92f9973b beb87bbd

verified
+1 -1
netlify/functions/batch-search-actors.ts
··· 3 3 import { successResponse } from "./shared/utils"; 4 4 import { withAuthErrorHandling } from "./shared/middleware"; 5 5 import { ValidationError } from "./shared/constants/errors"; 6 + import { normalize } from "./shared/utils/string.utils"; 6 7 7 8 const batchSearchHandler: AuthenticatedHandler = async (context) => { 8 9 // Parse batch request ··· 32 33 }); 33 34 34 35 // Filter and rank matches 35 - const normalize = (s: string) => s.toLowerCase().replace(/[._-]/g, ""); 36 36 const normalizedUsername = normalize(username); 37 37 38 38 const rankedActors = response.data.actors
+3 -6
netlify/functions/save-results.ts
··· 5 5 MatchRepository, 6 6 } from "./shared/repositories"; 7 7 import { successResponse } from "./shared/utils"; 8 + import { normalize } from "./shared/utils"; 8 9 import { withAuthErrorHandling } from "./shared/middleware"; 9 10 import { ValidationError } from "./shared/constants/errors"; 10 11 ··· 98 99 // BULK OPERATION 2: Link all users to source accounts 99 100 const links = results 100 101 .map((result) => { 101 - const normalized = result.sourceUser.username 102 - .toLowerCase() 103 - .replace(/[._-]/g, ""); 102 + const normalized = normalize(result.sourceUser.username); 104 103 const sourceAccountId = sourceAccountIdMap.get(normalized); 105 104 return { 106 105 sourceAccountId: sourceAccountId!, ··· 127 126 const matchedSourceAccountIds: number[] = []; 128 127 129 128 for (const result of results) { 130 - const normalized = result.sourceUser.username 131 - .toLowerCase() 132 - .replace(/[._-]/g, ""); 129 + const normalized = normalize(result.sourceUser.username); 133 130 const sourceAccountId = sourceAccountIdMap.get(normalized); 134 131 135 132 if (
+3 -3
netlify/functions/shared/repositories/SourceAccountRepository.ts
··· 1 1 import { BaseRepository } from "./BaseRepository"; 2 - import { SourceAccountRow } from "../types"; 2 + import { normalize } from "../utils"; 3 3 4 4 export class SourceAccountRepository extends BaseRepository { 5 5 /** ··· 9 9 sourcePlatform: string, 10 10 sourceUsername: string, 11 11 ): Promise<number> { 12 - const normalized = sourceUsername.toLowerCase().replace(/[._-]/g, ""); 12 + const normalized = normalize(sourceUsername); 13 13 14 14 const result = await this.sql` 15 15 INSERT INTO source_accounts (source_platform, source_username, normalized_username) ··· 32 32 const values = usernames.map((username) => ({ 33 33 platform: sourcePlatform, 34 34 username: username, 35 - normalized: username.toLowerCase().replace(/[._-]/g, ""), 35 + normalized: normalize(username), 36 36 })); 37 37 38 38 const platforms = values.map((v) => v.platform);
+31 -14
netlify/functions/shared/services/oauth/config.ts
··· 1 1 import { OAuthConfig } from "../../types"; 2 + import { configCache } from "../../utils/cache.utils"; 2 3 3 4 export function getOAuthConfig(event?: { 4 5 headers: Record<string, string | undefined>; 5 6 }): OAuthConfig { 7 + // Create a cache key based on the environment 8 + const host = event?.headers?.host || "default"; 9 + const cacheKey = `oauth-config-${host}`; 10 + 11 + // Check cache first 12 + const cached = configCache.get(cacheKey) as OAuthConfig | undefined; 13 + if (cached) { 14 + return cached; 15 + } 16 + 6 17 let baseUrl: string | undefined; 7 18 let deployContext: string | undefined; 8 19 ··· 11 22 deployContext = event.headers["x-nf-deploy-context"]; 12 23 13 24 // For Netlify deploys, construct URL from host header 14 - const host = event.headers.host; 15 25 const forwardedProto = event.headers["x-forwarded-proto"] || "https"; 16 26 17 27 if (host && !host.includes("localhost") && !host.includes("127.0.0.1")) { ··· 38 48 const isLocalhost = 39 49 !baseUrl || baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1"); 40 50 51 + let config: OAuthConfig; 52 + 41 53 if (isLocalhost) { 42 54 const port = process.env.PORT || "8888"; 43 55 const clientId = `http://localhost?${new URLSearchParams([ ··· 50 62 51 63 console.log("Using loopback OAuth for local development"); 52 64 53 - return { 65 + config = { 54 66 clientId: clientId, 55 67 redirectUri: `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 56 68 jwksUri: undefined, 57 69 clientType: "loopback", 58 70 }; 59 - } 71 + } else { 72 + // Production/Preview: discoverable client 73 + if (!baseUrl) { 74 + throw new Error("No public URL available for OAuth configuration"); 75 + } 76 + 77 + console.log("Using confidential OAuth client for:", baseUrl); 60 78 61 - // Production/Preview: discoverable client 62 - if (!baseUrl) { 63 - throw new Error("No public URL available for OAuth configuration"); 79 + config = { 80 + clientId: `${baseUrl}/oauth-client-metadata.json`, 81 + redirectUri: `${baseUrl}/.netlify/functions/oauth-callback`, 82 + jwksUri: `${baseUrl}/.netlify/functions/jwks`, 83 + clientType: "discoverable", 84 + usePrivateKey: true, 85 + }; 64 86 } 65 87 66 - console.log("Using confidential OAuth client for:", baseUrl); 88 + // Cache the config for 5 minutes (300000ms) 89 + configCache.set(cacheKey, config, 300000); 67 90 68 - return { 69 - clientId: `${baseUrl}/oauth-client-metadata.json`, 70 - redirectUri: `${baseUrl}/.netlify/functions/oauth-callback`, 71 - jwksUri: `${baseUrl}/.netlify/functions/jwks`, 72 - clientType: "discoverable", 73 - usePrivateKey: true, 74 - }; 91 + return config; 75 92 }
+49
netlify/functions/shared/utils/cache.utils.ts
··· 1 + /** 2 + * Simple in-memory cache with TTL support 3 + **/ 4 + class SimpleCache<T> { 5 + private cache = new Map<string, { value: T; expires: number }>(); 6 + 7 + set(key: string, value: T, ttlMs: number): void { 8 + this.cache.set(key, { 9 + value, 10 + expires: Date.now() + ttlMs, 11 + }); 12 + } 13 + 14 + get(key: string): T | undefined { 15 + const entry = this.cache.get(key); 16 + if (!entry) return undefined; 17 + 18 + if (Date.now() > entry.expires) { 19 + this.cache.delete(key); 20 + return undefined; 21 + } 22 + 23 + return entry.value; 24 + } 25 + 26 + has(key: string): boolean { 27 + return this.get(key) !== undefined; 28 + } 29 + 30 + delete(key: string): void { 31 + this.cache.delete(key); 32 + } 33 + 34 + clear(): void { 35 + this.cache.clear(); 36 + } 37 + 38 + // Clean up expired entries 39 + cleanup(): void { 40 + const now = Date.now(); 41 + for (const [key, entry] of this.cache.entries()) { 42 + if (now > entry.expires) { 43 + this.cache.delete(key); 44 + } 45 + } 46 + } 47 + } 48 + 49 + export const configCache = new SimpleCache<any>();
+2
netlify/functions/shared/utils/index.ts
··· 1 1 export * from "./response.utils"; 2 + export * from "./cache.utils"; 3 + export * from "./string.utils";
+9
netlify/functions/shared/utils/string.utils.ts
··· 1 + /** 2 + * Normalize a string for comparison purposes 3 + * 4 + * @param str - The string to normalize 5 + * @returns Normalized lowercase string 6 + **/ 7 + export function normalize(str: string): string { 8 + return str.toLowerCase().replace(/[._-]/g, ""); 9 + }
+1 -9
src/components/HistoryTab.tsx
··· 3 3 import type { Upload as UploadType } from "../types"; 4 4 import FaviconIcon from "../components/FaviconIcon"; 5 5 import type { UserSettings } from "../types/settings"; 6 + import { getPlatformColor } from "../lib/utils/platform"; 6 7 7 8 interface HistoryTabProps { 8 9 uploads: UploadType[]; ··· 30 31 hour: "2-digit", 31 32 minute: "2-digit", 32 33 }); 33 - }; 34 - 35 - const getPlatformColor = (platform: string) => { 36 - const colors: Record<string, string> = { 37 - tiktok: "from-black via-gray-800 to-cyan-400", 38 - twitter: "from-blue-400 to-blue-600", 39 - instagram: "from-pink-500 via-purple-500 to-orange-500", 40 - }; 41 - return colors[platform] || "from-gray-400 to-gray-600"; 42 34 }; 43 35 44 36 return (
+2 -6
src/components/SearchResultCard.tsx
··· 5 5 ChevronDown, 6 6 UserCheck, 7 7 } from "lucide-react"; 8 - import { PLATFORMS } from "../constants/platforms"; 9 - import { ATPROTO_APPS } from "../constants/atprotoApps"; 10 8 import type { SearchResult } from "../types"; 9 + import { getPlatform, getAtprotoAppWithFallback } from "../lib/utils/platform"; 11 10 import type { AtprotoAppId } from "../types/settings"; 12 11 13 12 interface SearchResultCardProps { ··· 33 32 ? result.atprotoMatches 34 33 : result.atprotoMatches.slice(0, 1); 35 34 const hasMoreMatches = result.atprotoMatches.length > 1; 36 - const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok; 37 - 38 - // Get current follow lexicon 39 - const currentApp = ATPROTO_APPS[destinationAppId]; 35 + const currentApp = getAtprotoAppWithFallback(destinationAppId); 40 36 const currentLexicon = currentApp?.followLexicon || "app.bsky.graph.follow"; 41 37 42 38 return (
+3 -8
src/constants/atprotoApps.ts
··· 1 1 import type { AtprotoApp } from "../types/settings"; 2 2 3 + // Re-export for convenience 4 + export type { AtprotoApp } from "../types/settings"; 5 + 3 6 export const ATPROTO_APPS: Record<string, AtprotoApp> = { 4 7 bluesky: { 5 8 id: "bluesky", ··· 43 46 enabled: false, // Not yet implemented 44 47 }, 45 48 }; 46 - 47 - export function getAppById(id: string): AtprotoApp | undefined { 48 - return ATPROTO_APPS[id]; 49 - } 50 - 51 - export function getEnabledApps(): AtprotoApp[] { 52 - return Object.values(ATPROTO_APPS).filter((app) => app.enabled); 53 - }
+12
src/constants/platforms.ts
··· 84 84 export const FOLLOW_CONFIG = { 85 85 BATCH_SIZE: 50, 86 86 }; 87 + 88 + /** 89 + * @deprecated Use getPlatformColor from lib/utils/platform instead 90 + **/ 91 + export function getLegacyPlatformColor(platform: string): string { 92 + const colors: Record<string, string> = { 93 + tiktok: "from-black via-gray-800 to-cyan-400", 94 + twitter: "from-blue-400 to-blue-600", 95 + instagram: "from-pink-500 via-purple-500 to-orange-500", 96 + }; 97 + return colors[platform] || "from-gray-400 to-gray-600"; 98 + }
+1 -1
src/lib/parserLogic.ts
··· 5 5 * @param content The string content (HTML or plain text) to search within. 6 6 * @param regexPattern The regex string defining the capture group for the username. 7 7 * @returns An array of extracted usernames. 8 - */ 8 + **/ 9 9 export function parseTextOrHtml( 10 10 content: string, 11 11 regexPattern: string,
+1
src/lib/utils/index.ts
··· 1 + export * from "./platform";
+95
src/lib/utils/platform.ts
··· 1 + import { PLATFORMS, type PlatformConfig } from "../../constants/platforms"; 2 + import { ATPROTO_APPS, type AtprotoApp } from "../../constants/atprotoApps"; 3 + import type { AtprotoAppId } from "../../types/settings"; 4 + 5 + /** 6 + * Get platform configuration by key 7 + * 8 + * @param platformKey - The platform identifier (e.g., "tiktok", "instagram") 9 + * @returns Platform configuration or default to TikTok 10 + **/ 11 + export function getPlatform(platformKey: string): PlatformConfig { 12 + return PLATFORMS[platformKey] || PLATFORMS.tiktok; 13 + } 14 + 15 + /** 16 + * Get platform gradient color classes for UI 17 + * 18 + * @param platformKey - The platform identifier 19 + * @returns Tailwind gradient classes for the platform 20 + **/ 21 + export function getPlatformColor(platformKey: string): string { 22 + const colors: Record<string, string> = { 23 + tiktok: "from-black via-gray-800 to-cyan-400", 24 + twitter: "from-blue-400 to-blue-600", 25 + instagram: "from-pink-500 via-purple-500 to-orange-500", 26 + tumblr: "from-indigo-600 to-blue-800", 27 + twitch: "from-purple-600 to-purple-800", 28 + youtube: "from-red-600 to-red-700", 29 + }; 30 + return colors[platformKey] || "from-gray-400 to-gray-600"; 31 + } 32 + 33 + /** 34 + * Get ATProto app configuration by ID 35 + * 36 + * @param appId - The app identifier 37 + * @returns App configuration or undefined if not found 38 + **/ 39 + export function getAtprotoApp(appId: AtprotoAppId): AtprotoApp | undefined { 40 + return ATPROTO_APPS[appId]; 41 + } 42 + 43 + /** 44 + * Get ATProto app with fallback to default 45 + * 46 + * @param appId - The app identifier 47 + * @param defaultApp - Default app ID to use as fallback 48 + * @returns App configuration, falling back to default or Bluesky 49 + **/ 50 + export function getAtprotoAppWithFallback( 51 + appId: AtprotoAppId, 52 + defaultApp: AtprotoAppId = "bluesky", 53 + ): AtprotoApp { 54 + return ( 55 + ATPROTO_APPS[appId] || ATPROTO_APPS[defaultApp] || ATPROTO_APPS.bluesky 56 + ); 57 + } 58 + 59 + /** 60 + * Get all enabled ATProto apps 61 + * 62 + * @returns Array of enabled app configurations 63 + **/ 64 + export function getEnabledAtprotoApps(): AtprotoApp[] { 65 + return Object.values(ATPROTO_APPS).filter((app) => app.enabled); 66 + } 67 + 68 + /** 69 + * Get all enabled platforms 70 + * 71 + * @returns Array of [key, config] tuples for enabled platforms 72 + **/ 73 + export function getEnabledPlatforms(): Array<[string, PlatformConfig]> { 74 + return Object.entries(PLATFORMS).filter(([_, config]) => config.enabled); 75 + } 76 + 77 + /** 78 + * Check if a platform is enabled 79 + * 80 + * @param platformKey - The platform identifier 81 + * @returns True if platform is enabled 82 + **/ 83 + export function isPlatformEnabled(platformKey: string): boolean { 84 + return PLATFORMS[platformKey]?.enabled || false; 85 + } 86 + 87 + /** 88 + * Check if an app is enabled 89 + * 90 + * @param appId - The app identifier 91 + * @returns True if app is enabled 92 + **/ 93 + export function isAppEnabled(appId: AtprotoAppId): boolean { 94 + return ATPROTO_APPS[appId]?.enabled || false; 95 + }
+2 -2
src/pages/Loading.tsx
··· 1 1 import AppHeader from "../components/AppHeader"; 2 - import { PLATFORMS } from "../constants/platforms"; 2 + import { getPlatform } from "../lib/utils/platform"; 3 3 4 4 interface atprotoSession { 5 5 did: string; ··· 40 40 onToggleTheme, 41 41 onToggleMotion, 42 42 }: LoadingPageProps) { 43 - const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok; 43 + const platform = getPlatform(sourcePlatform); 44 44 const PlatformIcon = platform.icon; 45 45 46 46 return (
+3 -4
src/pages/Results.tsx
··· 1 1 import { Sparkles } from "lucide-react"; 2 2 import { useMemo } from "react"; 3 - import { PLATFORMS } from "../constants/platforms"; 4 - import { ATPROTO_APPS } from "../constants/atprotoApps"; 5 3 import AppHeader from "../components/AppHeader"; 6 4 import SearchResultCard from "../components/SearchResultCard"; 7 5 import FaviconIcon from "../components/FaviconIcon"; 8 6 import type { AtprotoAppId } from "../types/settings"; 7 + import { getPlatform, getAtprotoApp } from "../lib/utils/platform"; 9 8 10 9 interface atprotoSession { 11 10 did: string; ··· 74 73 onToggleTheme, 75 74 onToggleMotion, 76 75 }: ResultsPageProps) { 77 - const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok; 76 + const platform = getPlatform(sourcePlatform); 77 + const destinationApp = getAtprotoApp(destinationAppId); 78 78 const PlatformIcon = platform.icon; 79 - const destinationApp = ATPROTO_APPS[destinationAppId]; 80 79 81 80 // Memoize sorted results to avoid re-sorting on every render 82 81 const sortedResults = useMemo(() => {
+32
src/types/auth.types.ts
··· 1 + // Authentication and session types 2 + 3 + export interface AtprotoSession { 4 + did: string; 5 + handle: string; 6 + displayName?: string; 7 + avatar?: string; 8 + description?: string; 9 + } 10 + 11 + export interface UserSessionData { 12 + did: string; 13 + } 14 + 15 + export interface OAuthConfig { 16 + clientId: string; 17 + redirectUri: string; 18 + jwksUri?: string; 19 + clientType: "loopback" | "discoverable"; 20 + usePrivateKey?: boolean; 21 + } 22 + 23 + export interface StateData { 24 + dpopKey: any; 25 + verifier: string; 26 + appState?: string; 27 + } 28 + 29 + export interface SessionData { 30 + dpopKey: any; 31 + tokenSet: any; 32 + }
+26
src/types/common.types.ts
··· 1 + // Common shared types 2 + 3 + export type AppStep = 4 + | "checking" 5 + | "login" 6 + | "home" 7 + | "upload" 8 + | "loading" 9 + | "results"; 10 + 11 + export interface Upload { 12 + uploadId: string; 13 + sourcePlatform: string; 14 + createdAt: string; 15 + totalUsers: number; 16 + matchedUsers: number; 17 + unmatchedUsers: number; 18 + } 19 + 20 + export interface SaveResultsResponse { 21 + success: boolean; 22 + uploadId: string; 23 + totalUsers: number; 24 + matchedUsers: number; 25 + unmatchedUsers: number; 26 + }
+4 -81
src/types/index.ts
··· 1 1 // Session and Auth Types 2 - export interface AtprotoSession { 3 - did: string; 4 - handle: string; 5 - displayName?: string; 6 - avatar?: string; 7 - description?: string; 8 - } 9 - 10 - // TikTok Data Types 11 - export interface SourceUser { 12 - username: string; 13 - date: string; 14 - } 2 + export * from "./auth.types"; 15 3 16 4 // Search and Match Types 17 - export interface AtprotoMatch { 18 - did: string; 19 - handle: string; 20 - displayName?: string; 21 - avatar?: string; 22 - matchScore: number; 23 - description?: string; 24 - followed?: boolean; // DEPRECATED - kept for backward compatibility 25 - followStatus?: Record<string, boolean>; 26 - postCount?: number; 27 - followerCount?: number; 28 - foundAt?: string; 29 - } 5 + export * from "./search.types"; 30 6 31 - export interface SearchResult { 32 - sourceUser: SourceUser; 33 - atprotoMatches: AtprotoMatch[]; 34 - isSearching: boolean; 35 - error?: string; 36 - selectedMatches?: Set<string>; 37 - sourcePlatform: string; 38 - } 39 - 40 - // Progress Tracking 41 - export interface SearchProgress { 42 - searched: number; 43 - found: number; 44 - total: number; 45 - } 46 - 47 - // App State 48 - export type AppStep = 49 - | "checking" 50 - | "login" 51 - | "home" 52 - | "upload" 53 - | "loading" 54 - | "results"; 55 - 56 - // API Response Types 57 - export interface BatchSearchResult { 58 - username: string; 59 - actors: AtprotoMatch[]; 60 - error?: string; 61 - } 62 - 63 - export interface BatchFollowResult { 64 - did: string; 65 - success: boolean; 66 - alreadyFollowing?: boolean; 67 - error: string | null; 68 - } 69 - 70 - export interface SaveResultsResponse { 71 - success: boolean; 72 - uploadId: string; 73 - totalUsers: number; 74 - matchedUsers: number; 75 - unmatchedUsers: number; 76 - } 77 - 78 - export interface Upload { 79 - uploadId: string; 80 - sourcePlatform: string; 81 - createdAt: string; 82 - totalUsers: number; 83 - matchedUsers: number; 84 - unmatchedUsers: number; 85 - } 7 + // Common Types 8 + export * from "./common.types"; 86 9 87 10 // Re-export settings types for convenience 88 11 export type {
+46
src/types/search.types.ts
··· 1 + export interface SourceUser { 2 + username: string; 3 + date: string; 4 + } 5 + 6 + export interface AtprotoMatch { 7 + did: string; 8 + handle: string; 9 + displayName?: string; 10 + avatar?: string; 11 + matchScore: number; 12 + description?: string; 13 + followed?: boolean; // DEPRECATED - kept for backward compatibility 14 + followStatus?: Record<string, boolean>; 15 + postCount?: number; 16 + followerCount?: number; 17 + foundAt?: string; 18 + } 19 + 20 + export interface SearchResult { 21 + sourceUser: SourceUser; 22 + atprotoMatches: AtprotoMatch[]; 23 + isSearching: boolean; 24 + error?: string; 25 + selectedMatches?: Set<string>; 26 + sourcePlatform: string; 27 + } 28 + 29 + export interface SearchProgress { 30 + searched: number; 31 + found: number; 32 + total: number; 33 + } 34 + 35 + export interface BatchSearchResult { 36 + username: string; 37 + actors: AtprotoMatch[]; 38 + error?: string; 39 + } 40 + 41 + export interface BatchFollowResult { 42 + did: string; 43 + success: boolean; 44 + alreadyFollowing?: boolean; 45 + error: string | null; 46 + }