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

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Vite dev server for popup UI development

- Add vite.config.ts with browser mock aliasing for webextension-polyfill
- Add popup-dev.html/ts with dev toolbar for testing UI states
- Add browser-mock.ts to simulate extension APIs in browser
- Add dev:popup script to root and extension package.json
- HMR support for rapid UI iteration

byarielm.fyi da3c93f0 93c2b5f4

verified
+860 -128
+2 -1
package.json
··· 6 6 "type": "module", 7 7 "scripts": { 8 8 "dev": "npx netlify-cli dev --filter @atlast/web", 9 - "dev:mock": "pnpm dev:mock --filter @atlast/web", 9 + "dev:mock": "pnpm --filter @atlast/web dev", 10 10 "dev:full": "npx netlify-cli dev --filter @atlast/web", 11 + "dev:popup": "pnpm --filter @atlast/extension dev:popup", 11 12 "build": "pnpm --filter @atlast/web build", 12 13 "init-db": "tsx scripts/init-local-db.ts", 13 14 "generate-key": "tsx scripts/generate-encryption-key.ts"
+3 -1
packages/extension/package.json
··· 8 8 "build": "node build.js", 9 9 "build:prod": "node build.js --prod", 10 10 "dev": "node build.js --watch", 11 + "dev:popup": "vite", 11 12 "package:chrome": "cd dist/chrome && zip -r ../chrome.zip .", 12 13 "package:firefox": "cd dist/firefox && zip -r ../firefox.zip .", 13 14 "package:all": "pnpm run package:chrome && pnpm run package:firefox", ··· 25 26 "esbuild": "^0.19.11", 26 27 "postcss": "^8.5.6", 27 28 "tailwindcss": "^3.4.19", 28 - "typescript": "^5.3.3" 29 + "typescript": "^5.3.3", 30 + "vite": "^5.4.21" 29 31 } 30 32 }
+143
packages/extension/src/popup/mocks/browser-mock.ts
··· 1 + // packages/extension/src/popup/mocks/browser-mock.ts 2 + /** 3 + * Mock browser API for Vite dev server 4 + * Simulates extension behavior without actual WebExtension APIs 5 + */ 6 + 7 + interface MockStorage { 8 + local: { 9 + get: (key: string | string[]) => Promise<any>; 10 + set: (items: any) => Promise<void>; 11 + remove: (keys: string | string[]) => Promise<void>; 12 + onChanged: { 13 + addListener: (callback: Function) => void; 14 + removeListener: (callback: Function) => void; 15 + }; 16 + }; 17 + } 18 + 19 + interface MockRuntime { 20 + getManifest: () => { version: string }; 21 + sendMessage: (message: any) => Promise<any>; 22 + onMessage: { 23 + addListener: (callback: Function) => void; 24 + removeListener: (callback: Function) => void; 25 + }; 26 + } 27 + 28 + interface MockTabs { 29 + create: (options: { url: string }) => Promise<void>; 30 + query: (options: any) => Promise<any[]>; 31 + sendMessage: (tabId: number, message: any) => Promise<any>; 32 + } 33 + 34 + // Mock state storage 35 + let mockState = { 36 + extensionState: { 37 + status: "idle" as const, 38 + platform: undefined, 39 + pageType: undefined, 40 + progress: undefined, 41 + result: undefined, 42 + error: undefined, 43 + }, 44 + }; 45 + 46 + const storageListeners: Function[] = []; 47 + 48 + // Mock storage API 49 + const storage: MockStorage = { 50 + local: { 51 + get: async (key) => { 52 + console.log("[Mock Browser] storage.local.get:", key); 53 + if (typeof key === "string") { 54 + return { [key]: mockState[key as keyof typeof mockState] }; 55 + } 56 + return mockState; 57 + }, 58 + set: async (items) => { 59 + console.log("[Mock Browser] storage.local.set:", items); 60 + mockState = { ...mockState, ...items }; 61 + 62 + // Trigger change listeners 63 + storageListeners.forEach((listener) => { 64 + Object.keys(items).forEach((key) => { 65 + listener( 66 + { 67 + [key]: { 68 + newValue: items[key], 69 + oldValue: mockState[key as keyof typeof mockState], 70 + }, 71 + }, 72 + "local", 73 + ); 74 + }); 75 + }); 76 + }, 77 + remove: async (keys) => { 78 + console.log("[Mock Browser] storage.local.remove:", keys); 79 + const keysArray = Array.isArray(keys) ? keys : [keys]; 80 + keysArray.forEach((key) => { 81 + delete mockState[key as keyof typeof mockState]; 82 + }); 83 + }, 84 + onChanged: { 85 + addListener: (callback: Function) => { 86 + storageListeners.push(callback); 87 + }, 88 + removeListener: (callback: Function) => { 89 + const index = storageListeners.indexOf(callback); 90 + if (index > -1) storageListeners.splice(index, 1); 91 + }, 92 + }, 93 + }, 94 + }; 95 + 96 + // Mock runtime API 97 + const runtime: MockRuntime = { 98 + getManifest: () => { 99 + console.log("[Mock Browser] runtime.getManifest"); 100 + return { version: "1.0.0-dev" }; 101 + }, 102 + sendMessage: async (message) => { 103 + console.log("[Mock Browser] runtime.sendMessage:", message); 104 + 105 + // Simulate message responses based on type 106 + if (message.type === "GET_STATE") { 107 + return mockState.extensionState; 108 + } 109 + 110 + return { success: true }; 111 + }, 112 + onMessage: { 113 + addListener: (callback: Function) => { 114 + console.log("[Mock Browser] runtime.onMessage.addListener"); 115 + }, 116 + removeListener: (callback: Function) => { 117 + console.log("[Mock Browser] runtime.onMessage.removeListener"); 118 + }, 119 + }, 120 + }; 121 + 122 + // Mock tabs API 123 + const tabs: MockTabs = { 124 + create: async (options) => { 125 + console.log("[Mock Browser] tabs.create:", options); 126 + window.open(options.url, "_blank"); 127 + }, 128 + query: async (options) => { 129 + console.log("[Mock Browser] tabs.query:", options); 130 + return [{ id: 1, url: "https://x.com/example/following" }]; 131 + }, 132 + sendMessage: async (tabId, message) => { 133 + console.log("[Mock Browser] tabs.sendMessage:", tabId, message); 134 + return { success: true }; 135 + }, 136 + }; 137 + 138 + // Export mock browser object 139 + export default { 140 + storage, 141 + runtime, 142 + tabs, 143 + };
+225
packages/extension/src/popup/popup-dev.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>ATlast Importer - Dev Mode</title> 7 + <link rel="stylesheet" href="popup.css" /> 8 + </head> 9 + <body 10 + class="w-[350px] min-h-[400px] font-sans text-slate-800 dark:text-cyan-50 bg-gradient-to-br from-purple-50 via-white to-cyan-50 dark:from-slate-900 dark:via-purple-950 dark:to-sky-900" 11 + > 12 + <!-- Dev Mode Banner --> 13 + <div 14 + style=" 15 + background: #f97316; 16 + color: white; 17 + text-align: center; 18 + padding: 4px; 19 + font-size: 11px; 20 + font-weight: bold; 21 + " 22 + > 23 + 🔧 DEVELOPMENT MODE 24 + </div> 25 + 26 + <div class="flex flex-col min-h-[400px]"> 27 + <header class="bg-firefly-banner text-white p-5 text-center"> 28 + <h1 class="text-xl font-bold mb-1">ATlast Importer</h1> 29 + <p class="text-[13px] opacity-90"> 30 + Find your follows in the ATmosphere 31 + </p> 32 + </header> 33 + 34 + <main 35 + id="app" 36 + class="flex-1 px-5 py-6 flex items-center justify-center" 37 + > 38 + <!-- Idle state --> 39 + <div id="state-idle" class="w-full text-center hidden"> 40 + <div class="text-5xl mb-4">🔍</div> 41 + <p 42 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 43 + > 44 + Go to your Twitter/X Following page to start 45 + </p> 46 + <p 47 + class="text-[13px] text-slate-500 dark:text-slate-400 mt-2" 48 + > 49 + Visit x.com/yourusername/following 50 + </p> 51 + </div> 52 + 53 + <!-- Ready state --> 54 + <div id="state-ready" class="w-full text-center hidden"> 55 + <div class="text-5xl mb-4">✅</div> 56 + <p 57 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 58 + > 59 + Ready to scan <span id="platform-name"></span> 60 + </p> 61 + <button 62 + id="btn-start" 63 + class="w-full bg-orange-600 hover:bg-orange-700 text-white font-semibold py-3 px-6 rounded-lg mt-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-orange-600/30 active:translate-y-0" 64 + > 65 + Start Scan 66 + </button> 67 + </div> 68 + 69 + <!-- Scraping state --> 70 + <div id="state-scraping" class="w-full text-center hidden"> 71 + <div class="text-5xl mb-4 spinner">⏳</div> 72 + <p 73 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 74 + > 75 + Scanning... 76 + </p> 77 + <div class="mt-5"> 78 + <div 79 + class="w-full h-2 bg-sky-50 dark:bg-slate-800 rounded overflow-hidden mb-3" 80 + > 81 + <div 82 + id="progress-fill" 83 + class="h-full bg-gradient-to-r from-orange-600 to-pink-600 w-0 transition-all duration-300 progress-fill" 84 + ></div> 85 + </div> 86 + <p 87 + class="text-base font-semibold text-slate-700 dark:text-cyan-50" 88 + > 89 + Found <span id="count">0</span> users 90 + </p> 91 + <p 92 + id="status-message" 93 + class="text-[13px] text-slate-500 dark:text-slate-400 mt-2" 94 + ></p> 95 + </div> 96 + </div> 97 + 98 + <!-- Complete state --> 99 + <div id="state-complete" class="w-full text-center hidden"> 100 + <div class="text-5xl mb-4">🎉</div> 101 + <p 102 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 103 + > 104 + Scan complete! 105 + </p> 106 + <p class="text-sm text-slate-500 dark:text-slate-400 mt-2"> 107 + Found 108 + <strong 109 + id="final-count" 110 + class="text-orange-600 dark:text-orange-400 text-lg" 111 + >0</strong 112 + > 113 + users 114 + </p> 115 + <button 116 + id="btn-upload" 117 + class="w-full bg-orange-600 hover:bg-orange-700 text-white font-semibold py-3 px-6 rounded-lg mt-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-orange-600/30 active:translate-y-0" 118 + > 119 + Open in ATlast 120 + </button> 121 + </div> 122 + 123 + <!-- Uploading state --> 124 + <div id="state-uploading" class="w-full text-center hidden"> 125 + <div class="text-5xl mb-4 spinner">📤</div> 126 + <p 127 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 128 + > 129 + Uploading to ATlast... 130 + </p> 131 + </div> 132 + 133 + <!-- Error state --> 134 + <div id="state-error" class="w-full text-center hidden"> 135 + <div class="text-5xl mb-4">⚠️</div> 136 + <p 137 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 138 + > 139 + Error 140 + </p> 141 + <p 142 + id="error-message" 143 + class="text-[13px] text-red-600 dark:text-red-400 mt-2 p-3 bg-red-50 dark:bg-red-950/50 rounded border-l-[3px] border-red-600" 144 + ></p> 145 + <button 146 + id="btn-retry" 147 + class="w-full bg-white dark:bg-purple-950 text-purple-700 dark:text-cyan-400 border-2 border-purple-700 dark:border-cyan-400 font-semibold py-2.5 px-6 rounded-lg mt-4 transition-all duration-200 hover:bg-purple-50 dark:hover:bg-purple-900" 148 + > 149 + Try Again 150 + </button> 151 + </div> 152 + 153 + <!-- Server offline state --> 154 + <div id="state-offline" class="w-full text-center hidden"> 155 + <div class="text-5xl mb-4">🔌</div> 156 + <p 157 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 158 + > 159 + Server not available 160 + </p> 161 + <p 162 + id="dev-instructions" 163 + class="text-[13px] text-red-600 dark:text-red-400 mt-2 p-3 bg-red-50 dark:bg-red-950/50 rounded border-l-[3px] border-red-600" 164 + > 165 + Start the dev server:<br /> 166 + <code 167 + class="bg-black/10 dark:bg-white/10 px-2 py-1 rounded font-mono text-[11px] inline-block my-2" 168 + >npx netlify-cli dev --filter @atlast/web</code 169 + > 170 + </p> 171 + <p 172 + class="text-[13px] text-slate-500 dark:text-slate-400 mt-2" 173 + id="server-url" 174 + ></p> 175 + <button 176 + id="btn-check-server" 177 + class="w-full bg-orange-600 hover:bg-orange-700 text-white font-semibold py-3 px-6 rounded-lg mt-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-orange-600/30 active:translate-y-0" 178 + > 179 + Check Again 180 + </button> 181 + </div> 182 + 183 + <!-- Not logged in state --> 184 + <div id="state-not-logged-in" class="w-full text-center hidden"> 185 + <div class="text-5xl mb-4">🔐</div> 186 + <p 187 + class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 188 + > 189 + Not logged in to ATlast 190 + </p> 191 + <p 192 + class="text-[13px] text-red-600 dark:text-red-400 mt-2 p-3 bg-red-50 dark:bg-red-950/50 rounded border-l-[3px] border-red-600" 193 + > 194 + Please log in to ATlast first, then return here to scan. 195 + </p> 196 + <button 197 + id="btn-open-atlast" 198 + class="w-full bg-orange-600 hover:bg-orange-700 text-white font-semibold py-3 px-6 rounded-lg mt-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-orange-600/30 active:translate-y-0" 199 + > 200 + Open ATlast 201 + </button> 202 + <button 203 + id="btn-retry-login" 204 + class="w-full bg-white dark:bg-purple-950 text-purple-700 dark:text-cyan-400 border-2 border-purple-700 dark:border-cyan-400 font-semibold py-2.5 px-6 rounded-lg mt-4 transition-all duration-200 hover:bg-purple-50 dark:hover:bg-purple-900" 205 + > 206 + Check Again 207 + </button> 208 + </div> 209 + </main> 210 + 211 + <footer 212 + class="p-4 text-center border-t border-purple-200 dark:border-slate-800 bg-white dark:bg-slate-900" 213 + > 214 + <a 215 + href="https://atlast.byarielm.fyi" 216 + target="_blank" 217 + class="text-orange-600 dark:text-orange-400 no-underline text-[13px] font-medium hover:underline" 218 + >atlast.byarielm.fyi</a 219 + > 220 + </footer> 221 + </div> 222 + 223 + <script type="module" src="./popup-dev.ts"></script> 224 + </body> 225 + </html>
+324
packages/extension/src/popup/popup-dev.ts
··· 1 + // packages/extension/src/popup/popup-dev.ts 2 + /** 3 + * Development entry point for popup UI 4 + * Simulates extension behavior for UI development 5 + */ 6 + 7 + import type { ExtensionState } from "../lib/messaging.js"; 8 + 9 + // Build mode injected at build time (mock for dev) 10 + declare const __BUILD_MODE__: string; 11 + const BUILD_MODE = "development"; 12 + 13 + // Mock browser object (handled by Vite alias) 14 + import browser from "webextension-polyfill"; 15 + 16 + /** 17 + * DOM elements 18 + */ 19 + const states = { 20 + idle: document.getElementById("state-idle")!, 21 + ready: document.getElementById("state-ready")!, 22 + scraping: document.getElementById("state-scraping")!, 23 + complete: document.getElementById("state-complete")!, 24 + uploading: document.getElementById("state-uploading")!, 25 + error: document.getElementById("state-error")!, 26 + offline: document.getElementById("state-offline")!, 27 + notLoggedIn: document.getElementById("state-not-logged-in")!, 28 + }; 29 + 30 + const elements = { 31 + platformName: document.getElementById("platform-name")!, 32 + count: document.getElementById("count")!, 33 + finalCount: document.getElementById("final-count")!, 34 + statusMessage: document.getElementById("status-message")!, 35 + errorMessage: document.getElementById("error-message")!, 36 + serverUrl: document.getElementById("server-url")!, 37 + devInstructions: document.getElementById("dev-instructions")!, 38 + progressFill: document.getElementById("progress-fill")! as HTMLElement, 39 + btnStart: document.getElementById("btn-start")! as HTMLButtonElement, 40 + btnUpload: document.getElementById("btn-upload")! as HTMLButtonElement, 41 + btnRetry: document.getElementById("btn-retry")! as HTMLButtonElement, 42 + btnCheckServer: document.getElementById( 43 + "btn-check-server", 44 + )! as HTMLButtonElement, 45 + btnOpenAtlast: document.getElementById( 46 + "btn-open-atlast", 47 + )! as HTMLButtonElement, 48 + btnRetryLogin: document.getElementById( 49 + "btn-retry-login", 50 + )! as HTMLButtonElement, 51 + }; 52 + 53 + /** 54 + * Show specific state, hide others 55 + */ 56 + function showState(stateName: keyof typeof states): void { 57 + Object.keys(states).forEach((key) => { 58 + states[key as keyof typeof states].classList.add("hidden"); 59 + }); 60 + states[stateName].classList.remove("hidden"); 61 + } 62 + 63 + /** 64 + * Update UI based on extension state 65 + */ 66 + function updateUI(state: ExtensionState): void { 67 + console.log("[Popup Dev] Updating UI with state:", state); 68 + 69 + switch (state.status) { 70 + case "idle": 71 + showState("idle"); 72 + break; 73 + 74 + case "ready": 75 + showState("ready"); 76 + if (state.platform) { 77 + const platformName = 78 + state.platform === "twitter" ? "Twitter/X" : state.platform; 79 + elements.platformName.textContent = platformName; 80 + } 81 + break; 82 + 83 + case "scraping": 84 + showState("scraping"); 85 + if (state.progress) { 86 + elements.count.textContent = state.progress.count.toString(); 87 + elements.statusMessage.textContent = state.progress.message || ""; 88 + 89 + // Animate progress bar 90 + const progress = Math.min(state.progress.count / 100, 1) * 100; 91 + elements.progressFill.style.width = `${progress}%`; 92 + } 93 + break; 94 + 95 + case "complete": 96 + showState("complete"); 97 + if (state.result) { 98 + elements.finalCount.textContent = state.result.totalCount.toString(); 99 + } 100 + break; 101 + 102 + case "uploading": 103 + showState("uploading"); 104 + break; 105 + 106 + case "error": 107 + showState("error"); 108 + elements.errorMessage.textContent = 109 + state.error || "An unknown error occurred"; 110 + break; 111 + 112 + default: 113 + showState("idle"); 114 + } 115 + } 116 + 117 + /** 118 + * Simulate state transitions for development 119 + */ 120 + let currentState: ExtensionState = { status: "idle" }; 121 + let simulationInterval: number | null = null; 122 + 123 + function simulateScraping() { 124 + let count = 0; 125 + currentState = { 126 + status: "scraping", 127 + platform: "twitter", 128 + pageType: "following", 129 + progress: { 130 + count: 0, 131 + status: "scraping", 132 + message: "Starting scan...", 133 + }, 134 + }; 135 + updateUI(currentState); 136 + 137 + simulationInterval = window.setInterval(() => { 138 + count += Math.floor(Math.random() * 25) + 5; 139 + 140 + if (count >= 247) { 141 + count = 247; 142 + if (simulationInterval) clearInterval(simulationInterval); 143 + 144 + currentState = { 145 + status: "complete", 146 + platform: "twitter", 147 + pageType: "following", 148 + result: { 149 + usernames: Array(247).fill("mockuser"), 150 + totalCount: 247, 151 + scrapedAt: new Date().toISOString(), 152 + }, 153 + }; 154 + updateUI(currentState); 155 + return; 156 + } 157 + 158 + currentState = { 159 + ...currentState, 160 + status: "scraping", 161 + progress: { 162 + count, 163 + status: "scraping", 164 + message: `Found ${count} users...`, 165 + }, 166 + }; 167 + updateUI(currentState); 168 + }, 500); 169 + } 170 + 171 + /** 172 + * Initialize popup 173 + */ 174 + async function init(): Promise<void> { 175 + console.log("[Popup Dev] Initializing development popup..."); 176 + 177 + // Show ready state for development 178 + currentState = { 179 + status: "ready", 180 + platform: "twitter", 181 + pageType: "following", 182 + }; 183 + updateUI(currentState); 184 + 185 + // Set up event listeners 186 + elements.btnStart.addEventListener("click", () => { 187 + console.log("[Popup Dev] Start scan clicked"); 188 + elements.btnStart.disabled = true; 189 + simulateScraping(); 190 + }); 191 + 192 + elements.btnUpload.addEventListener("click", () => { 193 + console.log("[Popup Dev] Upload clicked"); 194 + alert("In a real extension, this would open ATlast with your results!"); 195 + }); 196 + 197 + elements.btnRetry.addEventListener("click", () => { 198 + console.log("[Popup Dev] Retry clicked"); 199 + currentState = { 200 + status: "ready", 201 + platform: "twitter", 202 + pageType: "following", 203 + }; 204 + updateUI(currentState); 205 + elements.btnStart.disabled = false; 206 + }); 207 + 208 + elements.btnCheckServer.addEventListener("click", () => { 209 + console.log("[Popup Dev] Check server clicked"); 210 + currentState = { 211 + status: "ready", 212 + platform: "twitter", 213 + pageType: "following", 214 + }; 215 + updateUI(currentState); 216 + }); 217 + 218 + elements.btnOpenAtlast.addEventListener("click", () => { 219 + console.log("[Popup Dev] Open ATlast clicked"); 220 + window.open("http://127.0.0.1:8888", "_blank"); 221 + }); 222 + 223 + elements.btnRetryLogin.addEventListener("click", () => { 224 + console.log("[Popup Dev] Retry login clicked"); 225 + currentState = { 226 + status: "ready", 227 + platform: "twitter", 228 + pageType: "following", 229 + }; 230 + updateUI(currentState); 231 + }); 232 + 233 + // Add dev toolbar 234 + addDevToolbar(); 235 + 236 + console.log("[Popup Dev] Ready"); 237 + } 238 + 239 + /** 240 + * Add development toolbar for testing different states 241 + */ 242 + function addDevToolbar() { 243 + const toolbar = document.createElement("div"); 244 + toolbar.style.cssText = ` 245 + position: fixed; 246 + bottom: 0; 247 + left: 0; 248 + right: 0; 249 + background: #1e293b; 250 + color: white; 251 + padding: 8px; 252 + font-size: 12px; 253 + border-top: 2px solid #f97316; 254 + display: flex; 255 + gap: 8px; 256 + flex-wrap: wrap; 257 + z-index: 10000; 258 + `; 259 + 260 + const createButton = (label: string, state: ExtensionState) => { 261 + const btn = document.createElement("button"); 262 + btn.textContent = label; 263 + btn.style.cssText = ` 264 + background: #f97316; 265 + color: white; 266 + border: none; 267 + padding: 4px 8px; 268 + border-radius: 4px; 269 + cursor: pointer; 270 + font-size: 11px; 271 + `; 272 + btn.onclick = () => { 273 + currentState = state; 274 + updateUI(state); 275 + elements.btnStart.disabled = false; 276 + }; 277 + return btn; 278 + }; 279 + 280 + toolbar.innerHTML = '<strong style="margin-right: 8px;">Dev Tools:</strong>'; 281 + toolbar.appendChild(createButton("Idle", { status: "idle" })); 282 + toolbar.appendChild( 283 + createButton("Ready", { 284 + status: "ready", 285 + platform: "twitter", 286 + pageType: "following", 287 + }), 288 + ); 289 + toolbar.appendChild( 290 + createButton("Scraping", { 291 + status: "scraping", 292 + platform: "twitter", 293 + progress: { count: 42, status: "scraping", message: "Found 42 users..." }, 294 + }), 295 + ); 296 + toolbar.appendChild( 297 + createButton("Complete", { 298 + status: "complete", 299 + platform: "twitter", 300 + result: { 301 + usernames: [], 302 + totalCount: 247, 303 + scrapedAt: new Date().toISOString(), 304 + }, 305 + }), 306 + ); 307 + toolbar.appendChild( 308 + createButton("Error", { 309 + status: "error", 310 + error: "Failed to scrape page", 311 + }), 312 + ); 313 + toolbar.appendChild(createButton("Offline", { status: "idle" })); 314 + toolbar.appendChild(createButton("Not Logged In", { status: "idle" })); 315 + 316 + document.body.appendChild(toolbar); 317 + } 318 + 319 + // Initialize when DOM is ready 320 + if (document.readyState === "loading") { 321 + document.addEventListener("DOMContentLoaded", init); 322 + } else { 323 + init(); 324 + }
+21 -24
packages/extension/src/popup/popup.html
··· 7 7 <link rel="stylesheet" href="popup.css" /> 8 8 </head> 9 9 <body 10 - class="w-[350px] min-h-[400px] font-sans bg-gradient-to-br from-cyan-50 via-purple-50 to-pink-50 dark:from-indigo-950 dark:via-purple-900 dark:to-slate-900 text-slate-900 dark:text-slate-100 transition-colors duration-300 text-1xl font-bold mb-2 text-center" 10 + class="w-[350px] min-h-[400px] rounded-3xl font-sans bg-gradient-to-br from-cyan-50 via-purple-50 to-pink-50 dark:from-indigo-950 dark:via-purple-900 dark:to-slate-900 text-slate-900 dark:text-slate-100 transition-colors duration-300 mb-2 text-center" 11 11 > 12 12 <div class="flex flex-col min-h-[400px]"> 13 - <header class="bg-firefly-banner text-white p-5 text-center"> 14 - <h1 class="text-xl font-bold mb-1">ATlast Importer</h1> 15 - <p class="text-[13px] opacity-90"> 16 - Find your follows in the ATmosphere 13 + <header 14 + class="bg-white dark:bg-slate-900 border-b-2 border-cyan-500/50 dark:border-purple-500/50 p-5 text-center" 15 + > 16 + <h1 17 + class="text-xl font-bold text-purple-950 dark:text-cyan-50 space-x-3" 18 + > 19 + ATlast Importer 20 + </h1> 21 + <p class="text-sm text-purple-750 dark:text-cyan-250"> 22 + Find your people in the ATmosphere 17 23 </p> 18 24 </header> 19 25 20 - <main 21 - id="app" 22 - class="flex-1 px-5 py-6 flex items-center justify-center" 23 - > 26 + <main id="app" class="flex-1 px-5 py-6 flex"> 24 27 <!-- Idle state --> 25 28 <div id="state-idle" class="w-full text-center hidden"> 26 29 <div class="text-5xl mb-4">🔍</div> ··· 137 140 </div> 138 141 139 142 <!-- Server offline state --> 140 - <div id="state-offline" class="w-full text-center hidden"> 141 - <div class="text-5xl mb-4">🔌</div> 143 + <div id="state-offline" class="w-full hidden"> 144 + <div class="text-5xl text-center mb-4">🔌</div> 142 145 <p 143 - class="text-base font-semibold mb-3 text-slate-700 dark:text-cyan-50" 146 + class="text-base font-bold text-center mb-3 text-purple-950 dark:text-cyan-50" 144 147 > 145 148 Server not available 146 149 </p> 147 150 <p 148 151 id="dev-instructions" 149 - class="text-[13px] text-red-600 dark:text-red-400 mt-2 p-3 bg-red-50 dark:bg-red-950/50 rounded border-l-[3px] border-red-600" 152 + class="text-sm text-purple-900 dark:text-cyan-100 mt-2 p-3 bg-white/50 dark:bg-slate-900/50 rounded border-l-2 border-orange-600" 150 153 > 151 154 Start the dev server:<br /> 152 155 <code 153 - class="bg-black/10 dark:bg-white/10 px-2 py-1 rounded font-mono text-[11px] inline-block my-2" 156 + class="bg-black/10 dark:bg-white/10 px-2 py-1 rounded font-mono text-sm inline-block my-2" 154 157 >npx netlify-cli dev --filter @atlast/web</code 155 158 > 156 159 </p> 157 - <p 158 - class="text-[13px] text-slate-500 dark:text-slate-400 mt-2" 159 - id="server-url" 160 - ></p> 161 160 <button 162 161 id="btn-check-server" 163 - class="w-full bg-orange-600 hover:bg-orange-700 text-white font-semibold py-3 px-6 rounded-lg mt-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-orange-600/30 active:translate-y-0" 162 + class="w-full bg-orange-600 hover:bg-orange-500 text-white font-medium py-3 px-6 rounded-lg mt-4 shadow-md hover:shadow-lg transition-all duration-200" 164 163 > 165 164 Check Again 166 165 </button> ··· 194 193 </div> 195 194 </main> 196 195 197 - <footer 198 - class="p-4 text-center border-t border-purple-200 dark:border-slate-800 bg-white dark:bg-slate-900" 199 - > 196 + <footer class="text-center mb-6 rounded-3xl"> 200 197 <a 201 198 href="https://atlast.byarielm.fyi" 202 199 target="_blank" 203 - class="text-orange-600 dark:text-orange-400 no-underline text-[13px] font-medium hover:underline" 200 + class="text-purple-750 dark:text-cyan-250 no-underline text-l font-medium hover:underline" 204 201 >atlast.byarielm.fyi</a 205 202 > 206 203 </footer> 207 204 </div> 208 205 209 - <script type="module" src="popup.js"></script> 206 + <script type="module" src="popup.ts"></script> 210 207 </body> 211 208 </html>
+111 -102
packages/extension/src/popup/popup.ts
··· 1 - import browser from 'webextension-polyfill'; 1 + import browser from "webextension-polyfill"; 2 2 import { 3 3 MessageType, 4 4 sendToBackground, 5 5 sendToContent, 6 - type ExtensionState 7 - } from '../lib/messaging.js'; 6 + type ExtensionState, 7 + } from "../lib/messaging.js"; 8 8 9 9 // Build mode injected at build time 10 10 declare const __BUILD_MODE__: string; ··· 13 13 * DOM elements 14 14 */ 15 15 const states = { 16 - idle: document.getElementById('state-idle')!, 17 - ready: document.getElementById('state-ready')!, 18 - scraping: document.getElementById('state-scraping')!, 19 - complete: document.getElementById('state-complete')!, 20 - uploading: document.getElementById('state-uploading')!, 21 - error: document.getElementById('state-error')!, 22 - offline: document.getElementById('state-offline')!, 23 - notLoggedIn: document.getElementById('state-not-logged-in')! 16 + idle: document.getElementById("state-idle")!, 17 + ready: document.getElementById("state-ready")!, 18 + scraping: document.getElementById("state-scraping")!, 19 + complete: document.getElementById("state-complete")!, 20 + uploading: document.getElementById("state-uploading")!, 21 + error: document.getElementById("state-error")!, 22 + offline: document.getElementById("state-offline")!, 23 + notLoggedIn: document.getElementById("state-not-logged-in")!, 24 24 }; 25 25 26 26 const elements = { 27 - platformName: document.getElementById('platform-name')!, 28 - count: document.getElementById('count')!, 29 - finalCount: document.getElementById('final-count')!, 30 - statusMessage: document.getElementById('status-message')!, 31 - errorMessage: document.getElementById('error-message')!, 32 - serverUrl: document.getElementById('server-url')!, 33 - devInstructions: document.getElementById('dev-instructions')!, 34 - progressFill: document.getElementById('progress-fill')! as HTMLElement, 35 - btnStart: document.getElementById('btn-start')! as HTMLButtonElement, 36 - btnUpload: document.getElementById('btn-upload')! as HTMLButtonElement, 37 - btnRetry: document.getElementById('btn-retry')! as HTMLButtonElement, 38 - btnCheckServer: document.getElementById('btn-check-server')! as HTMLButtonElement, 39 - btnOpenAtlast: document.getElementById('btn-open-atlast')! as HTMLButtonElement, 40 - btnRetryLogin: document.getElementById('btn-retry-login')! as HTMLButtonElement 27 + platformName: document.getElementById("platform-name")!, 28 + count: document.getElementById("count")!, 29 + finalCount: document.getElementById("final-count")!, 30 + statusMessage: document.getElementById("status-message")!, 31 + errorMessage: document.getElementById("error-message")!, 32 + serverUrl: document.getElementById("server-url")!, 33 + devInstructions: document.getElementById("dev-instructions")!, 34 + progressFill: document.getElementById("progress-fill")! as HTMLElement, 35 + btnStart: document.getElementById("btn-start")! as HTMLButtonElement, 36 + btnUpload: document.getElementById("btn-upload")! as HTMLButtonElement, 37 + btnRetry: document.getElementById("btn-retry")! as HTMLButtonElement, 38 + btnCheckServer: document.getElementById( 39 + "btn-check-server", 40 + )! as HTMLButtonElement, 41 + btnOpenAtlast: document.getElementById( 42 + "btn-open-atlast", 43 + )! as HTMLButtonElement, 44 + btnRetryLogin: document.getElementById( 45 + "btn-retry-login", 46 + )! as HTMLButtonElement, 41 47 }; 42 48 43 49 /** 44 50 * Show specific state, hide others 45 51 */ 46 52 function showState(stateName: keyof typeof states): void { 47 - Object.keys(states).forEach(key => { 48 - states[key as keyof typeof states].classList.add('hidden'); 53 + Object.keys(states).forEach((key) => { 54 + states[key as keyof typeof states].classList.add("hidden"); 49 55 }); 50 - states[stateName].classList.remove('hidden'); 56 + states[stateName].classList.remove("hidden"); 51 57 } 52 58 53 59 /** 54 60 * Update UI based on extension state 55 61 */ 56 62 function updateUI(state: ExtensionState): void { 57 - console.log('[Popup] 🎨 Updating UI with state:', state); 58 - console.log('[Popup] 🎯 Current status:', state.status); 59 - console.log('[Popup] 🌐 Platform:', state.platform); 60 - console.log('[Popup] 📄 Page type:', state.pageType); 63 + console.log("[Popup] 🎨 Updating UI with state:", state); 64 + console.log("[Popup] 🎯 Current status:", state.status); 65 + console.log("[Popup] 🌐 Platform:", state.platform); 66 + console.log("[Popup] 📄 Page type:", state.pageType); 61 67 62 68 switch (state.status) { 63 - case 'idle': 64 - showState('idle'); 69 + case "idle": 70 + showState("idle"); 65 71 break; 66 72 67 - case 'ready': 68 - showState('ready'); 73 + case "ready": 74 + showState("ready"); 69 75 if (state.platform) { 70 - const platformName = state.platform === 'twitter' ? 'Twitter/X' : state.platform; 76 + const platformName = 77 + state.platform === "twitter" ? "Twitter/X" : state.platform; 71 78 elements.platformName.textContent = platformName; 72 79 } 73 80 break; 74 81 75 - case 'scraping': 76 - showState('scraping'); 82 + case "scraping": 83 + showState("scraping"); 77 84 if (state.progress) { 78 85 elements.count.textContent = state.progress.count.toString(); 79 - elements.statusMessage.textContent = state.progress.message || ''; 86 + elements.statusMessage.textContent = state.progress.message || ""; 80 87 81 88 // Animate progress bar 82 89 const progress = Math.min(state.progress.count / 100, 1) * 100; ··· 84 91 } 85 92 break; 86 93 87 - case 'complete': 88 - showState('complete'); 94 + case "complete": 95 + showState("complete"); 89 96 if (state.result) { 90 97 elements.finalCount.textContent = state.result.totalCount.toString(); 91 98 } 92 99 break; 93 100 94 - case 'uploading': 95 - showState('uploading'); 101 + case "uploading": 102 + showState("uploading"); 96 103 break; 97 104 98 - case 'error': 99 - showState('error'); 100 - elements.errorMessage.textContent = state.error || 'An unknown error occurred'; 105 + case "error": 106 + showState("error"); 107 + elements.errorMessage.textContent = 108 + state.error || "An unknown error occurred"; 101 109 break; 102 110 103 111 default: 104 - showState('idle'); 112 + showState("idle"); 105 113 } 106 114 } 107 115 ··· 113 121 elements.btnStart.disabled = true; 114 122 115 123 await sendToContent({ 116 - type: MessageType.START_SCRAPE 124 + type: MessageType.START_SCRAPE, 117 125 }); 118 126 119 127 // Poll for updates 120 128 pollForUpdates(); 121 129 } catch (error) { 122 - console.error('[Popup] Error starting scrape:', error); 123 - alert('Error: Make sure you are on a Twitter/X Following page'); 130 + console.error("[Popup] Error starting scrape:", error); 131 + alert("Error: Make sure you are on a Twitter/X Following page"); 124 132 elements.btnStart.disabled = false; 125 133 } 126 134 } ··· 131 139 async function uploadToATlast(): Promise<void> { 132 140 try { 133 141 elements.btnUpload.disabled = true; 134 - showState('uploading'); 142 + showState("uploading"); 135 143 136 144 const state = await sendToBackground<ExtensionState>({ 137 - type: MessageType.GET_STATE 145 + type: MessageType.GET_STATE, 138 146 }); 139 147 140 148 if (!state.result || !state.platform) { 141 - throw new Error('No scan results found'); 149 + throw new Error("No scan results found"); 142 150 } 143 151 144 152 if (state.result.usernames.length === 0) { 145 - throw new Error('No users found. Please scan the page first.'); 153 + throw new Error("No users found. Please scan the page first."); 146 154 } 147 155 148 156 // Import API client 149 - const { uploadToATlast: apiUpload, getExtensionVersion } = await import('../lib/api-client.js'); 157 + const { uploadToATlast: apiUpload, getExtensionVersion } = 158 + await import("../lib/api-client.js"); 150 159 151 160 // Prepare request 152 161 const request = { ··· 155 164 metadata: { 156 165 extensionVersion: getExtensionVersion(), 157 166 scrapedAt: state.result.scrapedAt, 158 - pageType: state.pageType || 'following', 159 - sourceUrl: window.location.href 160 - } 167 + pageType: state.pageType || "following", 168 + sourceUrl: window.location.href, 169 + }, 161 170 }; 162 171 163 172 // Upload to ATlast 164 173 const response = await apiUpload(request); 165 174 166 - console.log('[Popup] Upload successful:', response.importId); 175 + console.log("[Popup] Upload successful:", response.importId); 167 176 168 177 // Open ATlast at results page with upload data 169 - const { getApiUrl } = await import('../lib/api-client.js'); 178 + const { getApiUrl } = await import("../lib/api-client.js"); 170 179 const resultsUrl = `${getApiUrl()}${response.redirectUrl}`; 171 180 browser.tabs.create({ url: resultsUrl }); 172 - 173 181 } catch (error) { 174 - console.error('[Popup] Error uploading:', error); 175 - alert('Error uploading to ATlast. Please try again.'); 182 + console.error("[Popup] Error uploading:", error); 183 + alert("Error uploading to ATlast. Please try again."); 176 184 elements.btnUpload.disabled = false; 177 - showState('complete'); 185 + showState("complete"); 178 186 } 179 187 } 180 188 ··· 190 198 191 199 pollInterval = window.setInterval(async () => { 192 200 const state = await sendToBackground<ExtensionState>({ 193 - type: MessageType.GET_STATE 201 + type: MessageType.GET_STATE, 194 202 }); 195 203 196 204 updateUI(state); 197 205 198 206 // Stop polling when scraping is done 199 - if (state.status === 'complete' || state.status === 'error') { 207 + if (state.status === "complete" || state.status === "error") { 200 208 if (pollInterval) { 201 209 clearInterval(pollInterval); 202 210 pollInterval = null; ··· 209 217 * Check server health and show offline state if needed 210 218 */ 211 219 async function checkServer(): Promise<boolean> { 212 - console.log('[Popup] 🏥 Checking server health...'); 220 + console.log("[Popup] 🏥 Checking server health..."); 213 221 214 222 // Import health check function 215 - const { checkServerHealth, getApiUrl } = await import('../lib/api-client.js'); 223 + const { checkServerHealth, getApiUrl } = await import("../lib/api-client.js"); 216 224 217 225 const isOnline = await checkServerHealth(); 218 226 219 227 if (!isOnline) { 220 - console.log('[Popup] ❌ Server is offline'); 221 - showState('offline'); 228 + console.log("[Popup] ❌ Server is offline"); 229 + showState("offline"); 222 230 223 231 // Show appropriate message based on build mode 224 232 const apiUrl = getApiUrl(); 225 - const isDev = __BUILD_MODE__ === 'development'; 233 + const isDev = __BUILD_MODE__ === "development"; 226 234 227 235 // Hide dev instructions in production 228 236 if (!isDev) { 229 - elements.devInstructions.classList.add('hidden'); 237 + elements.devInstructions.classList.add("hidden"); 230 238 } 231 239 232 240 elements.serverUrl.textContent = isDev ··· 236 244 return false; 237 245 } 238 246 239 - console.log('[Popup] ✅ Server is online'); 247 + console.log("[Popup] ✅ Server is online"); 240 248 return true; 241 249 } 242 250 ··· 244 252 * Initialize popup 245 253 */ 246 254 async function init(): Promise<void> { 247 - console.log('[Popup] 🚀 Initializing popup...'); 255 + console.log("[Popup] 🚀 Initializing popup..."); 248 256 249 257 // Check server health first (only in dev mode) 250 - const { getApiUrl } = await import('../lib/api-client.js'); 251 - const isDev = getApiUrl().includes('127.0.0.1') || getApiUrl().includes('localhost'); 258 + const { getApiUrl } = await import("../lib/api-client.js"); 259 + const isDev = 260 + getApiUrl().includes("127.0.0.1") || getApiUrl().includes("localhost"); 252 261 253 262 if (isDev) { 254 263 const serverOnline = await checkServer(); 255 264 if (!serverOnline) { 256 265 // Set up retry button 257 - elements.btnCheckServer.addEventListener('click', async () => { 266 + elements.btnCheckServer.addEventListener("click", async () => { 258 267 elements.btnCheckServer.disabled = true; 259 - elements.btnCheckServer.textContent = 'Checking...'; 268 + elements.btnCheckServer.textContent = "Checking..."; 260 269 261 270 const online = await checkServer(); 262 271 if (online) { ··· 264 273 init(); 265 274 } else { 266 275 elements.btnCheckServer.disabled = false; 267 - elements.btnCheckServer.textContent = 'Check Again'; 276 + elements.btnCheckServer.textContent = "Check Again"; 268 277 } 269 278 }); 270 279 return; ··· 272 281 } 273 282 274 283 // Check if user is logged in to ATlast 275 - console.log('[Popup] 🔐 Checking login status...'); 276 - const { checkSession } = await import('../lib/api-client.js'); 284 + console.log("[Popup] 🔐 Checking login status..."); 285 + const { checkSession } = await import("../lib/api-client.js"); 277 286 const session = await checkSession(); 278 287 279 288 if (!session) { 280 - console.log('[Popup] ❌ Not logged in'); 281 - showState('notLoggedIn'); 289 + console.log("[Popup] ❌ Not logged in"); 290 + showState("notLoggedIn"); 282 291 283 292 // Set up login buttons 284 - elements.btnOpenAtlast.addEventListener('click', () => { 293 + elements.btnOpenAtlast.addEventListener("click", () => { 285 294 browser.tabs.create({ url: getApiUrl() }); 286 295 }); 287 296 288 - elements.btnRetryLogin.addEventListener('click', async () => { 297 + elements.btnRetryLogin.addEventListener("click", async () => { 289 298 elements.btnRetryLogin.disabled = true; 290 - elements.btnRetryLogin.textContent = 'Checking...'; 299 + elements.btnRetryLogin.textContent = "Checking..."; 291 300 292 301 const newSession = await checkSession(); 293 302 if (newSession) { ··· 295 304 init(); 296 305 } else { 297 306 elements.btnRetryLogin.disabled = false; 298 - elements.btnRetryLogin.textContent = 'Check Again'; 307 + elements.btnRetryLogin.textContent = "Check Again"; 299 308 } 300 309 }); 301 310 return; 302 311 } 303 312 304 - console.log('[Popup] ✅ Logged in as', session.handle); 313 + console.log("[Popup] ✅ Logged in as", session.handle); 305 314 306 315 // Get current state 307 - console.log('[Popup] 📡 Requesting state from background...'); 316 + console.log("[Popup] 📡 Requesting state from background..."); 308 317 const state = await sendToBackground<ExtensionState>({ 309 - type: MessageType.GET_STATE 318 + type: MessageType.GET_STATE, 310 319 }); 311 320 312 - console.log('[Popup] 📥 Received state from background:', state); 321 + console.log("[Popup] 📥 Received state from background:", state); 313 322 updateUI(state); 314 323 315 324 // Set up event listeners 316 - elements.btnStart.addEventListener('click', startScraping); 317 - elements.btnUpload.addEventListener('click', uploadToATlast); 318 - elements.btnRetry.addEventListener('click', async () => { 325 + elements.btnStart.addEventListener("click", startScraping); 326 + elements.btnUpload.addEventListener("click", uploadToATlast); 327 + elements.btnRetry.addEventListener("click", async () => { 319 328 const state = await sendToBackground<ExtensionState>({ 320 - type: MessageType.GET_STATE 329 + type: MessageType.GET_STATE, 321 330 }); 322 331 updateUI(state); 323 332 }); 324 333 325 334 // Listen for storage changes (when background updates state) 326 335 browser.storage.onChanged.addListener((changes, areaName) => { 327 - if (areaName === 'local' && changes.extensionState) { 336 + if (areaName === "local" && changes.extensionState) { 328 337 const newState = changes.extensionState.newValue; 329 - console.log('[Popup] 🔄 Storage changed, new state:', newState); 338 + console.log("[Popup] 🔄 Storage changed, new state:", newState); 330 339 updateUI(newState); 331 340 } 332 341 }); 333 342 334 343 // Poll for updates if currently scraping 335 - if (state.status === 'scraping') { 344 + if (state.status === "scraping") { 336 345 pollForUpdates(); 337 346 } 338 347 339 - console.log('[Popup] ✅ Popup ready'); 348 + console.log("[Popup] ✅ Popup ready"); 340 349 } 341 350 342 351 // Initialize when DOM is ready 343 - if (document.readyState === 'loading') { 344 - document.addEventListener('DOMContentLoaded', init); 352 + if (document.readyState === "loading") { 353 + document.addEventListener("DOMContentLoaded", init); 345 354 } else { 346 355 init(); 347 356 }
+28
packages/extension/vite.config.ts
··· 1 + import { defineConfig } from "vite"; 2 + import { resolve } from "path"; 3 + 4 + export default defineConfig({ 5 + root: "src/popup", 6 + base: "./", 7 + build: { 8 + outDir: "../../dist/popup-preview", 9 + emptyOutDir: true, 10 + }, 11 + server: { 12 + port: 5174, 13 + open: "/popup-dev.html", 14 + }, 15 + define: { 16 + __ATLAST_API_URL__: JSON.stringify("http://localhost:8888"), 17 + __BUILD_MODE__: JSON.stringify("development"), 18 + }, 19 + resolve: { 20 + alias: { 21 + // Mock webextension-polyfill for dev server 22 + "webextension-polyfill": resolve( 23 + __dirname, 24 + "src/popup/mocks/browser-mock.ts", 25 + ), 26 + }, 27 + }, 28 + });
+3
pnpm-lock.yaml
··· 142 142 typescript: 143 143 specifier: ^5.3.3 144 144 version: 5.9.3 145 + vite: 146 + specifier: ^5.4.21 147 + version: 5.4.21(@types/node@24.10.4) 145 148 146 149 packages/functions: 147 150 dependencies: