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

enable local dev + contributions :)

authored by byarielm.fyi and committed by byarielm.fyi 5ccf2b71 d3eacace

verified
+16
.env.example
··· 1 + # Copy below .env.mock for mock mode (frontend only); remove the rest 2 + VITE_LOCAL_MOCK=true 3 + VITE_ENABLE_OAUTH=false 4 + VITE_ENABLE_DATABASE=false 5 + 6 + # Copy this to .env for full local development and update marked fields 7 + VITE_LOCAL_MOCK=false 8 + VITE_API_BASE=/.netlify/functions 9 + URL=http://127.0.0.1:8888 10 + DEPLOY_URL=http://127.0.0.1:8888 11 + DEPLOY_PRIME_URL=http://127.0.0.1:8888 12 + CONTEXT=dev 13 + 14 + # Update these 15 + NETLIFY_DATABASE_URL=postgresql://user:password@host/database 16 + OAUTH_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END PRIVATE KEY-----"
+1
.gitignore
··· 1 1 .env 2 + .tangled/ 2 3 .vscode/ 3 4 .netlify/ 4 5 node_modules/
+253
CONTRIBUTING.md
··· 1 + # Contributing to ATlast 2 + 3 + Thank you for your interest in contributing! This guide will help you get started with local development. 4 + 5 + ## Two Development Modes 6 + 7 + We support two development modes: 8 + 9 + 🎨 **Mock Mode** (No backend required) 10 + **Best for:** Frontend development, UI/UX work, design changes 11 + 12 + 🔧 **Full Mode** (Complete backend) 13 + **Best for:** Backend development, API work, OAuth testing, database changes 14 + 15 + **Requirements:** 16 + - PostgreSQL database (local or Neon) 17 + - OAuth keys 18 + - Environment configuration 19 + 20 + --- 21 + 22 + ## Mock Mode Starting Guide 23 + 24 + Perfect for frontend contributors who want to jump in quickly! 25 + 26 + 1. Clone and Install 27 + ```bash 28 + git clone <repo-url> 29 + cd atlast 30 + npm install 31 + ``` 32 + 33 + 2. Create .env.local 34 + ```bash 35 + # .env.mock 36 + VITE_LOCAL_MOCK=true 37 + VITE_ENABLE_OAUTH=false 38 + VITE_ENABLE_DATABASE=false 39 + ``` 40 + 41 + 3. Start Development 42 + ```bash 43 + npm run dev:mock 44 + ``` 45 + 46 + 4. Open Your Browser 47 + Go to `http://localhost:5173` 48 + 49 + 5. "Login" with Mock User 50 + Enter any handle - it will create a mock session. 51 + 52 + 6. Upload Test Data 53 + Upload your TikTok or Instagram data file. The mock API will generate fake matches for testing the UI. 54 + 55 + --- 56 + 57 + ## Full Mode Starting Guide 58 + 59 + For contributors working on backend features, OAuth, or database operations. 60 + 61 + ### Prerequisites 62 + 63 + - Node.js 18+ 64 + - PostgreSQL (or Neon account) 65 + - OpenSSL (for key generation) 66 + 67 + 1. Clone and Install 68 + ```bash 69 + git clone <repo-url> 70 + cd atlast 71 + npm install 72 + npm install -g netlify-cli 73 + ``` 74 + 75 + 2. Database Setup 76 + 77 + **Option A: Neon (Recommended)** 78 + 1. Create account at https://neon.tech 79 + 2. Create project "atlast-dev" 80 + 3. Copy connection string 81 + 82 + **Option B: Local PostgreSQL** 83 + ```bash 84 + # macOS 85 + brew install postgresql@15 86 + brew services start postgresql@15 87 + createdb atlast_dev 88 + 89 + # Ubuntu 90 + sudo apt install postgresql 91 + sudo systemctl start postgresql 92 + sudo -u postgres createdb atlast_dev 93 + ``` 94 + 95 + 3. Generate OAuth Keys 96 + ```bash 97 + # Generate private key 98 + openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem 99 + 100 + # Extract public key 101 + openssl ec -in private-key.pem -pubout -out public-key.pem 102 + 103 + # View private key (copy for .env) 104 + cat private-key.pem 105 + ``` 106 + 107 + 4. Extract Public Key JWK 108 + ```bash 109 + node -e " 110 + const fs = require('fs'); 111 + const jose = require('jose'); 112 + const pem = fs.readFileSync('public-key.pem', 'utf8'); 113 + jose.importSPKI(pem, 'ES256').then(key => { 114 + return jose.exportJWK(key); 115 + }).then(jwk => { 116 + console.log(JSON.stringify(jwk, null, 2)); 117 + }); 118 + " 119 + ``` 120 + 121 + 5. Update netlify/functions/jwks.ts 122 + 123 + Replace `PUBLIC_JWK` with the output from step 4. 124 + 125 + 6. Create .env 126 + 127 + ```bash 128 + VITE_LOCAL_MOCK=false 129 + VITE_API_BASE=/.netlify/functions 130 + 131 + # Database (choose one) 132 + NETLIFY_DATABASE_URL=postgresql://user:pass@host/db # Neon 133 + # NETLIFY_DATABASE_URL=postgresql://localhost/atlast_dev # Local 134 + 135 + # OAuth (paste your private key) 136 + OAUTH_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END PRIVATE KEY-----" 137 + 138 + # Local URLs (MUST use 127.0.0.1 for OAuth) 139 + URL=http://127.0.0.1:8888 140 + DEPLOY_URL=http://127.0.0.1:8888 141 + DEPLOY_PRIME_URL=http://127.0.0.1:8888 142 + CONTEXT=dev 143 + ``` 144 + 145 + 7. Initialize Database 146 + ```bash 147 + npm run init-db 148 + ``` 149 + 150 + 8. Start Development Server 151 + ```bash 152 + npm run dev:full 153 + ``` 154 + 155 + 9. Test OAuth 156 + 157 + 1. Open `http://127.0.0.1:8888` (NOT localhost) 158 + 2. Enter your real Bluesky handle 159 + 3. Authorize the app 160 + 4. You should be redirected back and logged in 161 + 162 + --- 163 + 164 + ## Project Structure 165 + 166 + ``` 167 + atlast/ 168 + ├── src/ 169 + │ ├── components/ # React components 170 + │ ├── pages/ # Page components 171 + │ ├── hooks/ # Custom hooks 172 + │ ├── lib/ 173 + │ │ ├── apiClient/ # API client (real + mock) 174 + │ │ ├── platforms/ # File parsers 175 + │ │ └── config.ts # Environment config 176 + │ └── types/ # TypeScript types 177 + ├── netlify/ 178 + │ └── functions/ # Backend API 179 + ├── scripts/ # Build scripts 180 + └── test-data/ # Sample upload files (git-ignored) 181 + ``` 182 + 183 + --- 184 + 185 + ## Task Workflows 186 + 187 + ### Adding a New Social Platform 188 + 189 + 1. Create `src/lib/platforms/yourplatform.ts` 190 + 2. Implement parser following `tiktok.ts` or `instagram.ts` 191 + 3. Register in `src/lib/platforms/registry.ts` 192 + 4. Update `src/constants/platforms.ts` 193 + 5. Test with real data file 194 + 195 + ### Adding a New API Endpoint 196 + 197 + 1. Create `netlify/functions/your-endpoint.ts` 198 + 2. Add authentication check (copy from existing) 199 + 3. Update `src/lib/apiClient/realApiClient.ts` 200 + 4. Update `src/lib/apiClient/mockApiClient.ts` 201 + 5. Use in components via `apiClient.yourMethod()` 202 + 203 + ### Styling Changes 204 + 205 + - Use Tailwind utility classes 206 + - Follow dark mode pattern: `class="bg-white dark:bg-gray-800"` 207 + - Test in both light and dark modes 208 + - Mobile-first responsive design 209 + - Check accessibility (if implemented) is retained 210 + 211 + --- 212 + 213 + ## Submitting Changes 214 + 215 + ### Before Submitting 216 + 217 + - [ ] Test in mock mode: `npm run dev:mock` 218 + - [ ] Test in full mode (if backend changes): `npm run dev:full` 219 + - [ ] Check both light and dark themes 220 + - [ ] Test mobile responsiveness 221 + - [ ] No console errors 222 + - [ ] Code follows existing patterns 223 + 224 + ### Pull Request Process 225 + 226 + 1. Fork the repository 227 + 2. Create a feature branch: `git checkout -b feature/your-feature` 228 + 3. Make your changes 229 + 4. Commit with clear messages 230 + 5. Push to your fork 231 + 6. Open a Pull Request 232 + 233 + ### PR Description Should Include 234 + 235 + - What changes were made 236 + - Why these changes are needed 237 + - Screenshots (for UI changes) 238 + - Testing steps 239 + - Related issues 240 + 241 + --- 242 + 243 + ## Resources 244 + 245 + - [AT Protocol Docs](https://atproto.com) 246 + - [Bluesky API](https://docs.bsky.app) 247 + - [React Documentation](https://react.dev) 248 + - [Tailwind CSS](https://tailwindcss.com) 249 + - [Netlify Functions](https://docs.netlify.com/functions/overview) 250 + 251 + --- 252 + 253 + Thank you for contributing to ATlast!
+4 -2
package.json
··· 5 5 "version": "0.0.1", 6 6 "type": "module", 7 7 "scripts": { 8 - "dev": "vite", 8 + "dev": "netlify dev", 9 + "dev:mock": "vite --mode mock", 10 + "dev:full": "netlify dev", 9 11 "build": "vite build", 10 - "preview": "vite preview" 12 + "init-db": "tsx scripts/init-local-db.ts" 11 13 }, 12 14 "dependencies": { 13 15 "@atcute/identity": "^1.1.0",
+1 -1
src/lib/apiClient.ts src/lib/apiClient/realApiClient.ts
··· 1 - import type { AtprotoSession, BatchSearchResult, BatchFollowResult, SaveResultsResponse, SearchResult } from '../types'; 1 + import type { AtprotoSession, BatchSearchResult, BatchFollowResult, SaveResultsResponse, SearchResult } from '../../types'; 2 2 3 3 // Client-side cache with TTL 4 4 interface CacheEntry<T> {
+11
src/lib/apiClient/index.ts
··· 1 + import { isLocalMockMode } from '../config'; 2 + 3 + // Import both clients 4 + import { apiClient as realApiClient } from './realApiClient'; 5 + import { mockApiClient } from './mockApiClient'; 6 + 7 + // Export the appropriate client 8 + export const apiClient = isLocalMockMode() ? mockApiClient : realApiClient; 9 + 10 + // Also export both for explicit usage 11 + export { realApiClient, mockApiClient };
+181
src/lib/apiClient/mockApiClient.ts
··· 1 + import type { 2 + AtprotoSession, 3 + BatchSearchResult, 4 + BatchFollowResult, 5 + SearchResult, 6 + SaveResultsResponse 7 + } from '../../types'; 8 + 9 + // Mock user data for testing 10 + const MOCK_SESSION: AtprotoSession = { 11 + did: 'did:plc:mock123', 12 + handle: 'developer.bsky.social', 13 + displayName: 'Local Developer', 14 + avatar: undefined, 15 + description: 'Testing ATlast locally' 16 + }; 17 + 18 + // Generate mock Bluesky matches 19 + function generateMockMatches(username: string): any[] { 20 + const numMatches = Math.random() < 0.7 ? Math.floor(Math.random() * 3) + 1 : 0; 21 + 22 + return Array.from({ length: numMatches }, (_, i) => ({ 23 + did: `did:plc:mock${username}${i}`, 24 + handle: `${username}.bsky.social`, 25 + displayName: username.charAt(0).toUpperCase() + username.slice(1), 26 + avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}${i}`, 27 + matchScore: 100 - (i * 20), 28 + description: `Mock profile for ${username}`, 29 + postCount: Math.floor(Math.random() * 1000), 30 + followerCount: Math.floor(Math.random() * 5000), 31 + })); 32 + } 33 + 34 + // Simulate network delay 35 + const delay = (ms: number = 500) => new Promise(resolve => setTimeout(resolve, ms)); 36 + 37 + export const mockApiClient = { 38 + async startOAuth(handle: string): Promise<{ url: string }> { 39 + await delay(300); 40 + console.log('[MOCK] Starting OAuth for:', handle); 41 + // In mock mode, just return to home immediately 42 + return { url: window.location.origin + '/?session=mock' }; 43 + }, 44 + 45 + async getSession(): Promise<AtprotoSession> { 46 + await delay(200); 47 + console.log('[MOCK] Getting session'); 48 + 49 + // Check if user has "logged in" via mock OAuth 50 + const params = new URLSearchParams(window.location.search); 51 + if (params.get('session') === 'mock') { 52 + return MOCK_SESSION; 53 + } 54 + 55 + // Check localStorage for mock session 56 + const mockSession = localStorage.getItem('mock_session'); 57 + if (mockSession) { 58 + return JSON.parse(mockSession); 59 + } 60 + 61 + throw new Error('No mock session'); 62 + }, 63 + 64 + async logout(): Promise<void> { 65 + await delay(200); 66 + console.log('[MOCK] Logging out'); 67 + localStorage.removeItem('mock_session'); 68 + localStorage.removeItem('mock_uploads'); 69 + }, 70 + 71 + async getUploads(): Promise<{ uploads: any[] }> { 72 + await delay(300); 73 + console.log('[MOCK] Getting uploads'); 74 + 75 + const mockUploads = localStorage.getItem('mock_uploads'); 76 + if (mockUploads) { 77 + return { uploads: JSON.parse(mockUploads) }; 78 + } 79 + 80 + return { uploads: [] }; 81 + }, 82 + 83 + async getUploadDetails(uploadId: string, page: number = 1, pageSize: number = 50): Promise<{ 84 + results: SearchResult[]; 85 + pagination?: any; 86 + }> { 87 + await delay(500); 88 + console.log('[MOCK] Getting upload details:', uploadId); 89 + 90 + const mockData = localStorage.getItem(`mock_upload_${uploadId}`); 91 + if (mockData) { 92 + const results = JSON.parse(mockData); 93 + return { results }; 94 + } 95 + 96 + return { results: [] }; 97 + }, 98 + 99 + async getAllUploadDetails(uploadId: string): Promise<{ results: SearchResult[] }> { 100 + return this.getUploadDetails(uploadId); 101 + }, 102 + 103 + async batchSearchActors(usernames: string[]): Promise<{ results: BatchSearchResult[] }> { 104 + await delay(800); // Simulate API delay 105 + console.log('[MOCK] Searching for:', usernames); 106 + 107 + const results: BatchSearchResult[] = usernames.map(username => ({ 108 + username, 109 + actors: generateMockMatches(username), 110 + error: undefined 111 + })); 112 + 113 + return { results }; 114 + }, 115 + 116 + async batchFollowUsers(dids: string[]): Promise<{ 117 + success: boolean; 118 + total: number; 119 + succeeded: number; 120 + failed: number; 121 + results: BatchFollowResult[]; 122 + }> { 123 + await delay(1000); 124 + console.log('[MOCK] Following users:', dids); 125 + 126 + const results: BatchFollowResult[] = dids.map(did => ({ 127 + did, 128 + success: true, 129 + error: null 130 + })); 131 + 132 + return { 133 + success: true, 134 + total: dids.length, 135 + succeeded: dids.length, 136 + failed: 0, 137 + results 138 + }; 139 + }, 140 + 141 + async saveResults( 142 + uploadId: string, 143 + sourcePlatform: string, 144 + results: SearchResult[] 145 + ): Promise<SaveResultsResponse> { 146 + await delay(500); 147 + console.log('[MOCK] Saving results:', { uploadId, sourcePlatform, count: results.length }); 148 + 149 + // Save to localStorage 150 + localStorage.setItem(`mock_upload_${uploadId}`, JSON.stringify(results)); 151 + 152 + // Add to uploads list 153 + const uploads = JSON.parse(localStorage.getItem('mock_uploads') || '[]'); 154 + const matchedUsers = results.filter(r => r.atprotoMatches.length > 0).length; 155 + 156 + uploads.unshift({ 157 + uploadId, 158 + sourcePlatform, 159 + createdAt: new Date().toISOString(), 160 + totalUsers: results.length, 161 + matchedUsers, 162 + unmatchedUsers: results.length - matchedUsers 163 + }); 164 + 165 + localStorage.setItem('mock_uploads', JSON.stringify(uploads)); 166 + 167 + return { 168 + success: true, 169 + uploadId, 170 + totalUsers: results.length, 171 + matchedUsers, 172 + unmatchedUsers: results.length - matchedUsers 173 + }; 174 + }, 175 + 176 + cache: { 177 + clear: () => console.log('[MOCK] Cache cleared'), 178 + invalidate: (key: string) => console.log('[MOCK] Cache invalidated:', key), 179 + invalidatePattern: (pattern: string) => console.log('[MOCK] Cache pattern invalidated:', pattern), 180 + } 181 + }
+19
src/lib/config.ts
··· 1 + export const ENV = { 2 + // Detect if we're in local mock mode 3 + IS_LOCAL_MOCK: import.meta.env.VITE_LOCAL_MOCK === 'true', 4 + 5 + // API base URL 6 + API_BASE: import.meta.env.VITE_API_BASE || '/.netlify/functions', 7 + 8 + // Feature flags 9 + ENABLE_OAUTH: import.meta.env.VITE_ENABLE_OAUTH !== 'false', 10 + ENABLE_DATABASE: import.meta.env.VITE_ENABLE_DATABASE !== 'false', 11 + } as const; 12 + 13 + export function isLocalMockMode(): boolean { 14 + return ENV.IS_LOCAL_MOCK; 15 + } 16 + 17 + export function getApiUrl(endpoint: string): string { 18 + return `${ENV.API_BASE}/${endpoint}`; 19 + }
+10
src/vite-env.d.ts
··· 1 + interface ImportMetaEnv { 2 + readonly VITE_LOCAL_MOCK?: string; 3 + readonly VITE_API_BASE?: string; 4 + readonly VITE_ENABLE_OAUTH?: string; 5 + readonly VITE_ENABLE_DATABASE?: string; 6 + } 7 + 8 + interface ImportMeta { 9 + readonly env: ImportMetaEnv; 10 + }