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

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: