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

cleanup types, constants, utils

+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 + }