From c53ec9984c9db4dd9a5143cbc41c9d0b9e585e55 Mon Sep 17 00:00:00 2001 From: codingsh Date: Thu, 13 Feb 2025 01:33:52 +0000 Subject: [PATCH 1/2] feat(warpacast): add warpcast support --- src/background.ts | 102 ++++++++++++++++++++++++++----- src/components/PopupComponent.ts | 19 ++++-- src/content/content.ts | 64 +++++++++++++++---- src/manifest.json | 13 ++-- 4 files changed, 165 insertions(+), 33 deletions(-) diff --git a/src/background.ts b/src/background.ts index d043aec..3df30b0 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,4 +1,18 @@ // Types + +enum PlatformType { + TWITTER = 'twitter', + GITHUB = 'github', + FARCASTER = 'farcaster', + ENS = 'ens', + LENS = 'lens' +} + +interface IdentityQuery { + provider: string; + username: string; +} + interface Social { source: string; location: string | null; @@ -19,6 +33,16 @@ interface DataSources { profile_display_name: string; } +interface PassportProfile { + bio: string; + display_name: string; + image_url: string; + location: string | null; + name: string; + tags: string[]; +} + + interface PassportData { passport_id: number; activity_score: number; @@ -35,6 +59,7 @@ interface PassportData { location: string | null; tags: string[]; data_sources: DataSources; + passport_profile?: PassportProfile; verified: boolean; verified_wallets: string[]; onchain: boolean; @@ -106,6 +131,22 @@ const API_CONFIG = { CLEANUP_INTERVAL: 60 * 60 * 1000 // 1 hour } as const; + +class BackgroundPlatformService { + getIdentityQuery(platform: string, username: string): IdentityQuery { + switch (platform) { + case 'twitter': + return { provider: 'twitter', username }; + case 'github': + return { provider: 'github', username }; + case 'farcaster': + return { provider: 'farcaster', username }; + default: + return { provider: 'twitter', username }; // default fallback + } + } +} + // HTTP Client class HTTPClient { private lastRequestTime: number = 0; @@ -249,6 +290,28 @@ class DataProcessor { ); } + private getProfileData(passport: PassportData): { + bio: string; + displayName: string; + imageUrl: string; + } { + // Try to get data from passport_profile first + if (passport.passport_profile) { + return { + bio: passport.passport_profile.bio || '', + displayName: passport.passport_profile.display_name || passport.passport_profile.name || '', + imageUrl: passport.passport_profile.image_url || '' + }; + } + + // Fallback to original fields + return { + bio: passport.bio || '', + displayName: passport.display_name || passport.profile_name || '', + imageUrl: passport.image_url || '' + }; + } + processPassportData(apiResponse: APIResponse): BuilderScoreResponse { try { if (!apiResponse?.passports?.[0]) { @@ -280,15 +343,17 @@ class DataProcessor { return Math.min(Math.max(Math.round(score), 0), 100); }; + const profileData = this.getProfileData(passport); + return { success: true, data: { score: normalizeScore(passport.score), verified: Boolean(passport.verified || passport.human_checkmark), - displayName: passport.display_name || passport.profile_name || '', - bio: passport.bio || '', - imageUrl: passport.image_url || '', - passport_id: passport.passport_id, + displayName: profileData.displayName, + bio: profileData.bio, + imageUrl: profileData.imageUrl, + passport_id: passport.passport_id, socialProfiles, skills: { activity: normalizeScore(passport.activity_score), @@ -314,16 +379,20 @@ class BackgroundService { private cacheManager: CacheManager; private dataProcessor: DataProcessor; private pendingRequests: Map>; + private platformService: BackgroundPlatformService; constructor() { this.httpClient = new HTTPClient(); this.cacheManager = new CacheManager(); this.dataProcessor = new DataProcessor(); - this.pendingRequests = new Map(); + this.pendingRequests = new Map(); + this.platformService = new BackgroundPlatformService(); } - async getPassportData(username: string): Promise { + async getPassportData(request: { username: string; platform: string }): Promise { try { + const { username, platform } = request; + // Check for pending request const pending = this.pendingRequests.get(username); if (pending) { @@ -337,10 +406,10 @@ class BackgroundService { } // Create new request - const request = this.fetchPassportData(username); - this.pendingRequests.set(username, request); + const requestPromise = this.fetchPassportData(username, platform); + this.pendingRequests.set(username, requestPromise); - const response = await request; + const response = await requestPromise; this.pendingRequests.delete(username); if (response.success && response.data) { @@ -350,7 +419,7 @@ class BackgroundService { return response; } catch (error) { - this.pendingRequests.delete(username); + this.pendingRequests.delete(request.username); console.error('Error in getPassportData:', error); return { success: false, @@ -359,10 +428,12 @@ class BackgroundService { } } - private async fetchPassportData(username: string): Promise { + private async fetchPassportData(username: string, platform: string): Promise { try { + + const identityQuery = this.platformService.getIdentityQuery(platform, username); const response = await this.httpClient.fetchWithRetry( - `${API_CONFIG.BASE_URL}/${encodeURIComponent(username)}`, + `${API_CONFIG.BASE_URL}/${encodeURIComponent(username)}?provider=${identityQuery.provider}`, { method: 'GET', headers: { @@ -396,12 +467,15 @@ const backgroundService = new BackgroundService(); // Chrome extension message listener chrome.runtime.onMessage.addListener(( - request: { type: string; username: string }, + request: { type: string; username: string; platform: string }, sender: chrome.runtime.MessageSender, sendResponse: (response: BuilderScoreResponse) => void ) => { if (request.type === 'GET_PASSPORT_DATA') { - backgroundService.getPassportData(request.username) + backgroundService.getPassportData({ + username: request.username, + platform: request.platform + }) .then(response => { console.log('Sending response:', response); sendResponse(response); diff --git a/src/components/PopupComponent.ts b/src/components/PopupComponent.ts index a4c9f5f..0d81c99 100644 --- a/src/components/PopupComponent.ts +++ b/src/components/PopupComponent.ts @@ -125,6 +125,20 @@ class PopupComponent { }, 200); } + private createAvatar(imageUrl: string, displayName: string): string { + // Verificar se a URL da imagem é válida + const validImageUrl = imageUrl && imageUrl.startsWith('http') ? imageUrl : 'data:image/svg+xml;utf8,${displayName.charAt(0).toUpperCase()}'; + + return ` +
+ ${displayName} +
+ `; + } + private createPopup(): void { this.popup = document.createElement('div'); this.popup.style.cssText = this.baseStyles.popup; @@ -134,10 +148,7 @@ class PopupComponent { this.popup.innerHTML = `
-
- ${this.data.displayName} -
+ ${this.createAvatar(this.data.imageUrl, this.data.displayName)}

${this.data.displayName} diff --git a/src/content/content.ts b/src/content/content.ts index b019863..a2c3c11 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -1,6 +1,20 @@ import PopupComponent from "@/components/PopupComponent"; // Types and Interfaces + +enum PlatformType { + TWITTER = 'twitter', + GITHUB = 'github', + FARCASTER = 'farcaster', + ENS = 'ens', + LENS = 'lens' +} + +interface IdentityQuery { + provider: string; + username: string; +} + interface PassportData { passport_id: string; activity_score: number; @@ -56,9 +70,12 @@ const CONFIG = { PROFILE_NAME: 'div[data-testid="UserName"], h2[role="heading"], div[data-testid="UserCell"]', DISPLAY_NAME: 'div[dir="ltr"]' }, - WARPCAST: { - TAGS: 'div.flex.flex-row.items-center.space-x-2', - USERNAME: 'div.text-muted' + WARPCAST: { + PROFILE_CONTAINER: '.min-w-0.flex-auto.space-y-3', + USERNAME_CONTAINER: '.flex.flex-row.items-center.justify-between', + USERNAME: '.text-base.text-faint', + BADGE_CONTAINER: '.flex.flex-row.items-center.space-x-2', + STATS_CONTAINER: '.flex.w-full.flex-row.flex-wrap.gap-2' } }, PLATFORMS: { @@ -87,7 +104,7 @@ class Logger { } // Platform Service -class PlatformService { +export class PlatformService { getCurrentPlatform(): string { return window.location.hostname.includes('warpcast.com') ? CONFIG.PLATFORMS.WARPCAST @@ -97,10 +114,22 @@ class PlatformService { getUsername(platform: string, isTestMode: boolean): string | null { if (isTestMode) return 'testUser'; - if (platform === CONFIG.PLATFORMS.WARPCAST) { + if (platform === CONFIG.PLATFORMS.WARPCAST) { + const path = window.location.pathname; + const urlUsername = path.split('/').filter(p => p)[0]; + if (urlUsername && !['home', 'explore', 'notifications', 'user'].includes(urlUsername)) { + return urlUsername; + } + + const usernameElement = document.querySelector(CONFIG.SELECTORS.WARPCAST.USERNAME); - return usernameElement?.textContent?.trim() || null; + if (usernameElement) { + const username = usernameElement.textContent?.trim(); + return username?.startsWith('@') ? username.substring(1) : username; + } + return null; } else { + const path = window.location.pathname; if (!path) return null; @@ -113,14 +142,27 @@ class PlatformService { } getTargetElement(element: Element, platform: string): Element | null { - return platform === CONFIG.PLATFORMS.WARPCAST - ? element.querySelector(CONFIG.SELECTORS.WARPCAST.TAGS) - : element.querySelector(CONFIG.SELECTORS.TWITTER.DISPLAY_NAME); + if (platform === CONFIG.PLATFORMS.WARPCAST) { + const badgeContainer = element.querySelector(CONFIG.SELECTORS.WARPCAST.BADGE_CONTAINER); + if (badgeContainer) { + return badgeContainer; + } + + + const statsContainer = element.querySelector(CONFIG.SELECTORS.WARPCAST.STATS_CONTAINER); + if (statsContainer) { + return statsContainer; + } + + return null; + } else { + return element.querySelector(CONFIG.SELECTORS.TWITTER.DISPLAY_NAME); + } } getSelector(platform: string): string { return platform === CONFIG.PLATFORMS.WARPCAST - ? CONFIG.SELECTORS.WARPCAST.TAGS + ? CONFIG.SELECTORS.WARPCAST.PROFILE_CONTAINER : CONFIG.SELECTORS.TWITTER.PROFILE_NAME; } } @@ -358,7 +400,7 @@ class BuilderScoreController { return new Promise((resolve) => { chrome.runtime.sendMessage( - { type: 'GET_PASSPORT_DATA', username }, + { type: 'GET_PASSPORT_DATA', username, platform: this.platformService.getCurrentPlatform() }, (response: BuilderScoreResponse) => { if (!response) { resolve({ diff --git a/src/manifest.json b/src/manifest.json index 42058ca..a483e97 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "BuilderScore", - "version": "1.0.0", + "version": "1.0.1", "description": "Display Builder Scores from Talent Protocol on X profiles", "icons": { "16": "icons/icon-16.png", @@ -38,7 +38,8 @@ "*://*.x.com/*", "*://*.twitter.com/*", "*://*.github.com/*", - "*://*.warpcast.com/*", + "https://*.warpcast.com/*", + "https://warpcast.com/*", "*://*.talent.aipop.fun/*" ], "content_scripts": [ @@ -47,7 +48,9 @@ "*://*.x.com/*", "*://*.twitter.com/*", "*://*.github.com/*", - "*://*.warpcast.com/*" + "*://*.warpcast.com/*", + "https://*.warpcast.com/*", + "https://warpcast.com/*" ], "js": ["content.js"], @@ -56,7 +59,7 @@ "styles.css" ], "run_at": "document_idle", -"all_frames": false + "all_frames": false } ], "web_accessible_resources": [ @@ -69,6 +72,8 @@ "matches": [ "*://*.x.com/*", "*://*.twitter.com/*", + "https://*.warpcast.com/*", + "https://warpcast.com/*", "*://*.warpcast.com/*" ] } From 4b2951435b7c6d6d619b3e5fd1a3875b22e58fcd Mon Sep 17 00:00:00 2001 From: codingsh Date: Sat, 15 Feb 2025 01:49:13 +0000 Subject: [PATCH 2/2] feat(firefox): start enable firefox support --- .env.sample | 6 + .github/workflows/build.yaml | 120 +++++++++++++ manifest.chrome.json | 83 +++++++++ manifest.firefox.json | 38 ++++ package.json | 10 +- src/background.ts | 30 +++- src/components/Leaderboard.tsx | 149 ++++++++++++++++ src/content/components/BuilderLeaderboard.tsx | 109 ++++++++++++ src/content/components/BuilderScore.tsx | 165 ++++++++++++++++++ .../components/LeaderboardInjector.tsx | 129 ++++++++++++++ src/content/components/TwitterBadge.tsx | 126 +++++++++++++ src/content/content.ts | 102 ++++++++++- src/content/debug.ts | 70 ++++++++ src/content/store/usePassportStore.ts | 101 +++++++++++ src/manifest.json | 8 +- src/popup/store/useStore.ts | 152 ++++++++++++++++ src/utils/browser-api.ts | 65 +++++++ src/utils/safe-dom.ts | 66 +++++++ webpack.config.js | 12 +- 19 files changed, 1532 insertions(+), 9 deletions(-) create mode 100644 .env.sample create mode 100644 .github/workflows/build.yaml create mode 100644 manifest.chrome.json create mode 100644 manifest.firefox.json create mode 100644 src/components/Leaderboard.tsx create mode 100644 src/content/components/BuilderLeaderboard.tsx create mode 100644 src/content/components/BuilderScore.tsx create mode 100644 src/content/components/LeaderboardInjector.tsx create mode 100644 src/content/components/TwitterBadge.tsx create mode 100644 src/content/debug.ts create mode 100644 src/content/store/usePassportStore.ts create mode 100644 src/popup/store/useStore.ts create mode 100644 src/utils/browser-api.ts create mode 100644 src/utils/safe-dom.ts diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..ba87391 --- /dev/null +++ b/.env.sample @@ -0,0 +1,6 @@ +TALENT_PROTOCOL_API_KEY= +SUPABASE_URL= +SUPABASE_KEY= +DUNE_API_KEY= +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..8583dd7 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,120 @@ +name: Build Extension + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + # Allow manual trigger + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build Chrome extension + run: npm run build:chrome + env: + NODE_ENV: production + + - name: Build Firefox extension + run: npm run build:firefox + env: + NODE_ENV: production + + - name: Build Firefox XPI + run: npm run build:firefox-xpi + + - name: Package Chrome extension + run: | + cd dist/chrome + zip -r ../../chrome-extension.zip ./* + cd ../.. + + - name: Upload Chrome artifact + uses: actions/upload-artifact@v4 + with: + name: chrome-extension + path: chrome-extension.zip + + - name: Upload Firefox artifact + uses: actions/upload-artifact@v4 + with: + name: firefox-extension + path: web-ext-artifacts/*.xpi + + release: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download Chrome artifact + uses: actions/download-artifact@v4 + with: + name: chrome-extension + + - name: Download Firefox artifact + uses: actions/download-artifact@v4 + with: + name: firefox-extension + + - name: Get version + id: package_version + run: | + echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ env.VERSION }} + name: Release v${{ env.VERSION }} + draft: false + prerelease: false + files: | + chrome-extension.zip + *.xpi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install web-ext + run: npm install -g web-ext + + - name: Lint Firefox extension + run: | + npm run build:firefox + web-ext lint -s dist/firefox \ No newline at end of file diff --git a/manifest.chrome.json b/manifest.chrome.json new file mode 100644 index 0000000..f19dbb2 --- /dev/null +++ b/manifest.chrome.json @@ -0,0 +1,83 @@ +{ + "manifest_version": 3, + "name": "BuilderScore", + "version": "1.0.1", + "description": "Display Builder Scores from Talent Protocol on X profiles", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", + "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "permissions": [ + "storage", + "activeTab", + "tabs", + "scripting", + "alarms" + ], + "host_permissions": [ + "*://*.linkedin.com/*", + "*://*.talentprotocol.com/*", + "*://*.x.com/*", + "*://*.twitter.com/*", + "*://*.github.com/*", + "https://*.warpcast.com/*", + "https://warpcast.com/*", + "*://*.talent.aipop.fun/*" + ], + "content_scripts": [ + { + "matches": [ + "*://*.x.com/*", + "*://*.twitter.com/*", + "*://*.github.com/*", + "*://*.warpcast.com/*", + "https://*.warpcast.com/*", + "https://warpcast.com/*", + "*://*.linkedin.com/*" + ], + "js": ["content.js"], + "css": [ + "global.css", + "styles.css" + ], + "run_at": "document_idle", + "all_frames": false + } + ], + "web_accessible_resources": [ + { + "resources": [ + "icons/*", + "*.js", + "*.css" + ], + "matches": [ + "*://*.x.com/*", + "*://*.twitter.com/*", + "https://*.warpcast.com/*", + "https://warpcast.com/*", + "*://*.warpcast.com/*", + "*://*.linkedin.com/*" + ] + } + ] +} \ No newline at end of file diff --git a/manifest.firefox.json b/manifest.firefox.json new file mode 100644 index 0000000..7a4b638 --- /dev/null +++ b/manifest.firefox.json @@ -0,0 +1,38 @@ +{ + "manifest_version": 3, + "name": "Builder Score Extension", + "version": "1.0.1", + "description": "Extension to display Builder Scores on social platforms", + "permissions": [ + "storage", + "webRequest", + "*://*.talent.aipop.fun/*", + "*://*.twitter.com/*", + "*://*.warpcast.com/*", + "*://*.linkedin.com/*" + ], + "background": { + "scripts": ["background.js"], + "persistent": false + }, + "content_scripts": [ + { + "matches": [ + "https://*.twitter.com/*", + "https://*.warpcast.com/*", + "https://*.linkedin.com/*" + ], + "js": ["content.js"] + } + ], + "icons": { + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "builder-score@extension.org", + "strict_min_version": "57.0" + } + } +} diff --git a/package.json b/package.json index f367e67..bc0eb7f 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,17 @@ "author": "codingsh", "description": "Chrome extension to replace addresses with Farcaster names on DexScreener", "scripts": { - "build": "webpack --config webpack.config.js", - "dev": "webpack --config webpack.config.js --watch" + "build:chrome": "webpack --config webpack.config.js --env browser=chrome", + "build:firefox": "webpack --config webpack.config.js --env browser=firefox", + "build": "npm run build:chrome && npm run build:firefox", + "dev:chrome": "webpack --config webpack.config.js --env browser=chrome --watch", + "dev:firefox": "webpack --config webpack.config.js --env browser=firefox --watch", + "build:firefox-xpi": "web-ext build --source-dir ./dist/firefox --artifacts-dir ./web-ext-artifacts" }, "dependencies": { "@radix-ui/react-switch": "^1.1.1", "@supabase/supabase-js": "^2.48.1", + "@types/firefox-webext-browser": "^120.0.4", "autoprefix": "^1.0.1", "class-variance-authority": "^0.7.1", "dotenv": "^16.0.3", @@ -20,6 +25,7 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "terser-webpack-plugin": "^5.3.10", + "web-ext": "^8.4.0", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/src/background.ts b/src/background.ts index 3df30b0..3f34afc 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,4 +1,4 @@ -// Types +import { browserAPI } from './utils/browser-api'; enum PlatformType { TWITTER = 'twitter', @@ -460,10 +460,38 @@ class BackgroundService { }; } } + + + init() { + browserAPI.runtime.onMessage.addListener(async ( + request: { type: string; username: string; platform: string }, + sender: any, + sendResponse: (response: BuilderScoreResponse) => void + ) => { + if (request.type === 'GET_PASSPORT_DATA') { + try { + const response = await this.getPassportData({ + username: request.username, + platform: request.platform + }); + sendResponse(response); + } catch (error) { + console.error('Error in background:', error); + sendResponse({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } + } + return true; // Keep message channel open for async response + }); + } + } // Initialize the background service const backgroundService = new BackgroundService(); +backgroundService.init(); // Chrome extension message listener chrome.runtime.onMessage.addListener(( diff --git a/src/components/Leaderboard.tsx b/src/components/Leaderboard.tsx new file mode 100644 index 0000000..d38cb33 --- /dev/null +++ b/src/components/Leaderboard.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react' +import { supabase } from '../lib/supabase' + +interface LeaderboardUser { + passport_id: string + display_name: string + profile_name: string + image_url: string + score: number + activity_score: number + identity_score: number + skills_score: number + rank: number +} + +const Leaderboard = () => { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchLeaderboard = async () => { + try { + console.log('[Leaderboard] Buscando dados do Supabase...') + + const { data, error } = await supabase + .from('passport_records') + .select(` + passport_id, + display_name, + profile_name, + image_url, + score, + activity_score, + identity_score, + skills_score + `) + .order('score', { ascending: false }) + .limit(20) + + if (error) throw error + if (!data) throw new Error('Nenhum dado encontrado') + + console.log('[Leaderboard] Dados recebidos:', data) + + const rankedUsers = data.map((user, index) => ({ + ...user, + rank: index + 1 + })) + + setUsers(rankedUsers) + } catch (err) { + console.error('[Leaderboard] Erro:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + // Primeira carga + fetchLeaderboard() + + // Configurar atualizações em tempo real + const channel = supabase + .channel('realtime-passports') + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'passport_records' + }, + () => { + console.log('[WebSocket] Mudança detectada, atualizando...') + fetchLeaderboard() + } + ) + .subscribe() + + // Cleanup + return () => { + console.log('[WebSocket] Desinscrevendo...') + supabase.removeChannel(channel) + } + }, []) + + if (loading) { + return ( +
+
+ Carregando leaderboard... +
+ ) + } + + if (error) { + return ( +
+ Erro ao carregar leaderboard: {error} +
+ ) + } + + return ( +
+
+

🏆 Builder Leaderboard

+ Atualizado em tempo real +
+ +
+ {users.map(user => ( +
+
{user.rank}
+ {user.display_name} +
+
{user.display_name}
+
@{user.profile_name}
+
+
+ 🏅 Total + {user.score} +
+
+ ⚡ Atividade + {user.activity_score} +
+
+ 🆔 Identidade + {user.identity_score} +
+
+ 🎯 Habilidades + {user.skills_score} +
+
+
+
+ ))} +
+
+ ) +} + +export default Leaderboard \ No newline at end of file diff --git a/src/content/components/BuilderLeaderboard.tsx b/src/content/components/BuilderLeaderboard.tsx new file mode 100644 index 0000000..7d6db2a --- /dev/null +++ b/src/content/components/BuilderLeaderboard.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; +import { Trophy } from 'lucide-react'; +import { supabase } from '../../lib/supabase'; + +const BuilderLeaderboard = () => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [topBuilders, setTopBuilders] = useState([]); + + useEffect(() => { + const fetchTopBuilders = async () => { + try { + const { data, error } = await supabase + .from('passports') + .select('*') + .order('score', { ascending: false }) + .limit(5); + + if (error) throw error; + + setTopBuilders(data); + setLoading(false); + } catch (err) { + console.error('Error fetching top builders:', err); + setError('Failed to load leaderboard'); + setLoading(false); + } + }; + + fetchTopBuilders(); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return null; + } + + return ( + + ); +}; + +export default BuilderLeaderboard; \ No newline at end of file diff --git a/src/content/components/BuilderScore.tsx b/src/content/components/BuilderScore.tsx new file mode 100644 index 0000000..174cd4e --- /dev/null +++ b/src/content/components/BuilderScore.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from 'react'; +import { useStore } from '../../popup/store/useStore'; +import { AlertCircle, Loader2 } from 'lucide-react'; + +const BuilderScore = () => { + const [currentUsername, setCurrentUsername] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { getPassportData, fetchPassportFromSupabase } = useStore(); + + useEffect(() => { + const handleUrlChange = async () => { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const url = tabs[0]?.url; + + if (!url) { + setError("No active tab URL found"); + setLoading(false); + return; + } + + // Extract username from Twitter URL - support both twitter.com and x.com + const match = url.match(/(?:twitter|x)\.com\/([^/]+)/); + const username = match?.[1]; + + if (!username) { + setError("No Twitter profile detected"); + setLoading(false); + return; + } + + setCurrentUsername(username); + setLoading(true); + + // Check cache first + const cachedData = getPassportData(username); + if (cachedData) { + setLoading(false); + return; + } + + // Fetch from Supabase if not in cache + const passportData = await fetchPassportFromSupabase(username); + setLoading(false); + + if (!passportData) { + setError("No builder score found"); + } + } catch (err) { + console.error('Error fetching passport data:', err); + setError('Failed to fetch builder score'); + setLoading(false); + } + }; + + // Initial load + handleUrlChange(); + + // Listen for URL changes + chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.url) { + handleUrlChange(); + } + }); + + // Listen for tab changes + chrome.tabs.onActivated.addListener(handleUrlChange); + + return () => { + chrome.tabs.onUpdated.removeListener(handleUrlChange); + chrome.tabs.onActivated.removeListener(handleUrlChange); + }; + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (!currentUsername) { + return ( +
+ Visit a Twitter profile to see builder score +
+ ); + } + + const passportData = getPassportData(currentUsername); + const passport = passportData?.passport; + + if (!passport) { + return ( +
+ No builder score available for @{currentUsername} +
+ ); + } + + return ( +
+
+
+
+ +
+
+ {passport.passport_profile.display_name} + {passport.verified && ( + + )} +
+
@{currentUsername}
+
+
+
+ {passport.score} +
+
+ +
+
+ Activity + {passport.activity_score} +
+
+ Identity + {passport.identity_score} +
+
+ Skills + {passport.skills_score} +
+
+ + + View Full Profile + +
+
+ ); +}; + +export default BuilderScore; \ No newline at end of file diff --git a/src/content/components/LeaderboardInjector.tsx b/src/content/components/LeaderboardInjector.tsx new file mode 100644 index 0000000..1d50f13 --- /dev/null +++ b/src/content/components/LeaderboardInjector.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from 'react'; +import { useStore } from '../../popup/store/useStore'; + +const UserAvatar = ({ src, username, name }) => { + const [imageError, setImageError] = useState(false); + + return ( +
+
+
+
+
+
+
+ setImageError(true)} + className="css-9pa8cd" + /> +
+
+
+
+ ); +}; + +const FollowButton = () => ( + +); + +export const LeaderboardInjector = () => { + const [loading, setLoading] = useState(true); + const [topBuilders, setTopBuilders] = useState([]); + const { supabase } = useStore(); + + useEffect(() => { + const fetchTopBuilders = async () => { + try { + const { data, error } = await supabase + .from('passports') + .select('*') + .order('score', { ascending: false }) + .limit(3); + + if (error) throw error; + setTopBuilders(data); + } catch (err) { + console.error('Error fetching top builders:', err); + } finally { + setLoading(false); + } + }; + + fetchTopBuilders(); + }, []); + + if (loading || topBuilders.length === 0) return null; + + return ( +
+ +
+ ); +}; + +export default LeaderboardInjector; \ No newline at end of file diff --git a/src/content/components/TwitterBadge.tsx b/src/content/components/TwitterBadge.tsx new file mode 100644 index 0000000..6f1ec95 --- /dev/null +++ b/src/content/components/TwitterBadge.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Check, Star, Activity, User, Code } from 'lucide-react'; + +const TwitterBadge = ({ username, passport, className = '' }) => { + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState('bottom'); + const badgeRef = useRef(null); + + useEffect(() => { + const updatePosition = () => { + if (!badgeRef.current || !showTooltip) return; + const rect = badgeRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + setTooltipPosition(spaceBelow < 320 ? 'top' : 'bottom'); + }; + + updatePosition(); + window.addEventListener('scroll', updatePosition); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + }; + }, [showTooltip]); + + if (!passport) return null; + + const getScoreColor = (score) => { + if (score >= 80) return 'bg-purple-500'; + if (score >= 60) return 'bg-blue-500'; + if (score >= 40) return 'bg-green-500'; + return 'bg-gray-500'; + }; + + return ( +
+ + + {showTooltip && ( +
+
+ +
+
+ + {passport.passport_profile.display_name} + + {passport.verified && ( + + )} +
+
+ Builder Profile +
+
+
+ +
+ + + +
+ + + View Full Profile + +
+ )} +
+ ); +}; + +const ScoreCard = ({ icon: Icon, label, score }) => ( +
+ +
{label}
+
{score}
+
+); + +export default TwitterBadge; \ No newline at end of file diff --git a/src/content/content.ts b/src/content/content.ts index a2c3c11..ea017e2 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -76,11 +76,29 @@ const CONFIG = { USERNAME: '.text-base.text-faint', BADGE_CONTAINER: '.flex.flex-row.items-center.space-x-2', STATS_CONTAINER: '.flex.w-full.flex-row.flex-wrap.gap-2' + }, + LINKEDIN: { + // Profile page selectors + PROFILE_CONTAINER: '[class*="pv-text-details__left-panel"]', + PROFILE_NAME: 'h1[class*="text-heading-xlarge"]', + BADGE_CONTAINER: 'h1[class*="text-heading-xlarge"]', + + // Profile URL selectors + PROFILE_URL: 'a[href*="/in/"]', + + // Feed post selectors (for timeline) + FEED_PROFILE_NAME: 'span[class*="feed-shared-actor__name"]', + FEED_PROFILE_LINK: 'a[class*="feed-shared-actor__container"]', + + // Search results selectors + SEARCH_PROFILE_NAME: 'span[class*="entity-result__title-text"]', + SEARCH_PROFILE_LINK: 'a[class*="app-aware-link"]' } }, PLATFORMS: { TWITTER: 'twitter', - WARPCAST: 'warpcast' + WARPCAST: 'warpcast', + LINKEDIN: 'linkedin' } } as const; @@ -106,14 +124,50 @@ class Logger { // Platform Service export class PlatformService { getCurrentPlatform(): string { + if (window.location.hostname.includes('linkedin.com')) { + return CONFIG.PLATFORMS.LINKEDIN; + } return window.location.hostname.includes('warpcast.com') ? CONFIG.PLATFORMS.WARPCAST : CONFIG.PLATFORMS.TWITTER; } + private getLinkedInUsername(isTestMode: boolean): string | null { + if (isTestMode) return 'testUser'; + + // Try to get username from URL first + const pathname = window.location.pathname; + if (pathname.startsWith('/in/')) { + const username = pathname.split('/in/')[1]?.split('/')[0]; + if (username) return username; + } + + // Try to get from profile container + const profileLink = document.querySelector(CONFIG.SELECTORS.LINKEDIN.PROFILE_URL); + if (profileLink && profileLink instanceof HTMLAnchorElement) { + const href = profileLink.href; + const username = href.split('/in/')[1]?.split('/')[0]; + if (username) return username; + } + + // Try to get from feed or search results + const feedLink = document.querySelector(CONFIG.SELECTORS.LINKEDIN.FEED_PROFILE_LINK); + if (feedLink && feedLink instanceof HTMLAnchorElement) { + const href = feedLink.href; + const username = href.split('/in/')[1]?.split('/')[0]; + if (username) return username; + } + + return null; + } + getUsername(platform: string, isTestMode: boolean): string | null { if (isTestMode) return 'testUser'; + if (platform === CONFIG.PLATFORMS.LINKEDIN) { + return this.getLinkedInUsername(isTestMode); + } + if (platform === CONFIG.PLATFORMS.WARPCAST) { const path = window.location.pathname; const urlUsername = path.split('/').filter(p => p)[0]; @@ -142,6 +196,23 @@ export class PlatformService { } getTargetElement(element: Element, platform: string): Element | null { + + if (platform === CONFIG.PLATFORMS.LINKEDIN) { + // Check if we're on a profile page + const profileName = element.querySelector(CONFIG.SELECTORS.LINKEDIN.PROFILE_NAME); + if (profileName) return profileName; + + // Check if we're in feed + const feedName = element.querySelector(CONFIG.SELECTORS.LINKEDIN.FEED_PROFILE_NAME); + if (feedName) return feedName; + + // Check if we're in search results + const searchName = element.querySelector(CONFIG.SELECTORS.LINKEDIN.SEARCH_PROFILE_NAME); + if (searchName) return searchName; + + return null; + } + if (platform === CONFIG.PLATFORMS.WARPCAST) { const badgeContainer = element.querySelector(CONFIG.SELECTORS.WARPCAST.BADGE_CONTAINER); if (badgeContainer) { @@ -161,6 +232,13 @@ export class PlatformService { } getSelector(platform: string): string { + if (platform === CONFIG.PLATFORMS.LINKEDIN) { + return ` + ${CONFIG.SELECTORS.LINKEDIN.PROFILE_NAME}, + ${CONFIG.SELECTORS.LINKEDIN.FEED_PROFILE_NAME}, + ${CONFIG.SELECTORS.LINKEDIN.SEARCH_PROFILE_NAME} + `; + } return platform === CONFIG.PLATFORMS.WARPCAST ? CONFIG.SELECTORS.WARPCAST.PROFILE_CONTAINER : CONFIG.SELECTORS.TWITTER.PROFILE_NAME; @@ -234,6 +312,28 @@ class BadgeUIService { boxShadow: '0 2px 4px rgba(139, 92, 246, 0.2)' }; + + if (platform === CONFIG.PLATFORMS.LINKEDIN) { + badge.className = 'builder-score-badge linkedin-badge'; + Object.assign(badge.style, { + ...commonStyles, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + padding: '2px 8px', + borderRadius: '8px', + fontSize: '12px', + fontWeight: '600', + marginLeft: '8px', + verticalAlign: 'middle', + minWidth: '40px', + height: '20px', + lineHeight: '1.5', + fontFamily: '-apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial' + }); + } + if (platform === 'warpcast') { badge.className += ' flex w-max flex-row items-center space-x-1 rounded-full px-2 py-1 text-sm'; Object.assign(badge.style, { diff --git a/src/content/debug.ts b/src/content/debug.ts new file mode 100644 index 0000000..bf8658d --- /dev/null +++ b/src/content/debug.ts @@ -0,0 +1,70 @@ +// src/content/debug.ts +interface BuilderScoreDebug { + log: (...args: any[]) => void; + error: (...args: any[]) => void; + warn: (...args: any[]) => void; + enable: () => void; + disable: () => void; +} + +interface BuilderScoreUtils { + checkSelectors: (username: string) => void; + reinject: (username?: string) => Promise; + forceInit: () => void; +} + +interface BuilderScore { + debug: BuilderScoreDebug; + utils: BuilderScoreUtils; +} + +declare global { + interface Window { + BuilderScore: BuilderScore; + } +} + +// Criar objeto global para debug +const initializeDebug = () => { + console.log('[BuilderScore] Initializing debug utilities...'); + + if (!window.BuilderScore) { + window.BuilderScore = {} as BuilderScore; + } + + let DEBUG = true; + + window.BuilderScore.debug = { + log: (...args: any[]) => { + if (DEBUG) { + console.log('%c[BuilderScore]', 'background: #8a2be2; color: white; padding: 2px 5px; border-radius: 3px;', ...args); + } + }, + error: (...args: any[]) => { + if (DEBUG) { + console.error('%c[BuilderScore Error]', 'background: #ff0000; color: white; padding: 2px 5px; border-radius: 3px;', ...args); + } + }, + warn: (...args: any[]) => { + if (DEBUG) { + console.warn('%c[BuilderScore Warning]', 'background: #ffa500; color: white; padding: 2px 5px; border-radius: 3px;', ...args); + } + }, + enable: () => { + DEBUG = true; + window.BuilderScore.debug.log('Debugging enabled'); + }, + disable: () => { + DEBUG = false; + console.log('[BuilderScore] Debugging disabled'); + } + }; + + // Log inicial para confirmar que foi carregado + window.BuilderScore.debug.log('Debug utilities initialized'); +}; + +// Auto-inicializar +initializeDebug(); + +export { initializeDebug }; \ No newline at end of file diff --git a/src/content/store/usePassportStore.ts b/src/content/store/usePassportStore.ts new file mode 100644 index 0000000..558337b --- /dev/null +++ b/src/content/store/usePassportStore.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand'; + +interface PassportProfile { + bio: string; + display_name: string; + image_url: string; + location: string | null; + name: string; + tags: string[]; +} + +interface Passport { + activity_score: number; + calculating_score: boolean; + created_at: string; + human_checkmark: boolean; + identity_score: number; + last_calculated_at: string; + main_wallet: string; + onchain: boolean; + passport_id: number; + passport_profile: PassportProfile; + score: number; + skills_score: number; + socials_calculated_at: string; + verified: boolean; + verified_wallets: string[]; +} + +interface PassportState { + passports: Record; + loading: Record; + error: Record; + fetchPassport: (username: string) => Promise; + getPassport: (username: string) => Passport | null; + clearPassports: () => void; +} + +interface MessageResponse { + success: boolean; + data?: { + passports: Passport[]; + }; + error?: string; +} + +export const usePassportStore = create((set, get) => ({ + passports: {}, + loading: {}, + error: {}, + + fetchPassport: async (username: string) => { + try { + // Set loading state for this username + set((state) => ({ + loading: { ...state.loading, [username]: true }, + error: { ...state.error, [username]: null } + })); + + // Usa chrome.runtime.sendMessage para se comunicar com o background script + const response: MessageResponse = await new Promise((resolve) => { + chrome.runtime.sendMessage( + { type: 'FETCH_PASSPORT', username }, + (response) => { + resolve(response); + } + ); + }); + + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to fetch passport data'); + } + + const passport = response.data.passports[0]; + + if (passport) { + set((state) => ({ + passports: { ...state.passports, [username]: passport }, + loading: { ...state.loading, [username]: false } + })); + } else { + throw new Error('No passport found'); + } + } catch (err) { + console.error('Error fetching passport:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to load data'; + set((state) => ({ + loading: { ...state.loading, [username]: false }, + error: { ...state.error, [username]: errorMessage } + })); + } + }, + + getPassport: (username: string) => { + return get().passports[username] || null; + }, + + clearPassports: () => { + set({ passports: {}, loading: {}, error: {} }); + } +})); \ No newline at end of file diff --git a/src/manifest.json b/src/manifest.json index a483e97..f19dbb2 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -34,6 +34,7 @@ "alarms" ], "host_permissions": [ + "*://*.linkedin.com/*", "*://*.talentprotocol.com/*", "*://*.x.com/*", "*://*.twitter.com/*", @@ -50,8 +51,8 @@ "*://*.github.com/*", "*://*.warpcast.com/*", "https://*.warpcast.com/*", - "https://warpcast.com/*" - + "https://warpcast.com/*", + "*://*.linkedin.com/*" ], "js": ["content.js"], "css": [ @@ -74,7 +75,8 @@ "*://*.twitter.com/*", "https://*.warpcast.com/*", "https://warpcast.com/*", - "*://*.warpcast.com/*" + "*://*.warpcast.com/*", + "*://*.linkedin.com/*" ] } ] diff --git a/src/popup/store/useStore.ts b/src/popup/store/useStore.ts new file mode 100644 index 0000000..a260fa6 --- /dev/null +++ b/src/popup/store/useStore.ts @@ -0,0 +1,152 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { createClient } from '@supabase/supabase-js'; + +export interface PassportProfile { + bio: string; + display_name: string; + image_url: string; + name: string; + tags: string[]; +} + +export interface Passport { + passport_id: number; + activity_score: number; + identity_score: number; + skills_score: number; + score: number; + verified: boolean; + human_checkmark: boolean; + passport_profile: PassportProfile; + socials: Array<{ + source: string; + profile_url: string; + }>; +} + +export interface PassportData { + passport: Passport; +} + +interface BuilderStore { + passportCache: Record; + setPassportData: (username: string, data: PassportData) => void; + getPassportData: (username: string) => PassportData | null; + clearCache: () => void; + fetchPassportFromSupabase: (username: string) => Promise; + syncSupabaseProfile: (passportId: number, newUsername: string) => Promise; +} + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! +); + +export const useStore = create()( + persist( + (set, get) => ({ + passportCache: {}, + supabase, + setPassportData: (username: string, data: PassportData) => + set((state) => ({ + passportCache: { + ...state.passportCache, + [username.toLowerCase()]: data + }, + })), + + getPassportData: (username: string) => { + const cleanUsername = username.toLowerCase().replace(/^@/, ''); + return get().passportCache[cleanUsername] || null; + }, + + clearCache: () => set({ passportCache: {} }), + + fetchPassportFromSupabase: async (username: string) => { + try { + const cleanUsername = username.toLowerCase().replace(/^@/, ''); + + const { data, error } = await supabase + .from('passports') + .select('*') + .filter('socials', 'cs', { source: 'twitter' }) + .or(`profile_url.ilike.%${cleanUsername}`) + .single(); + + if (error || !data) { + console.error('Error fetching passport:', error); + return null; + } + + // Transform data to match PassportData interface + const passportData: PassportData = { + passport: { + passport_id: data.passport_id, + activity_score: data.activity_score, + identity_score: data.identity_score, + skills_score: data.skills_score, + score: data.score, + verified: data.verified, + human_checkmark: data.human_checkmark, + passport_profile: { + bio: data.bio, + display_name: data.display_name, + image_url: data.image_url, + name: data.profile_name, + tags: data.tags || [] + }, + socials: data.socials + } + }; + + // Update cache + get().setPassportData(cleanUsername, passportData); + return passportData; + + } catch (error) { + console.error('Error in fetchPassportFromSupabase:', error); + return null; + } + }, + + syncSupabaseProfile: async (passportId: number, newUsername: string) => { + try { + const cleanUsername = newUsername.toLowerCase().replace(/^@/, ''); + + // Update socials in Supabase + const { error: updateError } = await supabase + .from('passports') + .update({ + socials: [{ + source: 'twitter', + profile_url: `https://x.com/${cleanUsername}` + }] + }) + .eq('passport_id', passportId); + + if (updateError) { + console.error('Error updating passport:', updateError); + return; + } + + // Fetch updated data + const updatedPassport = await get().fetchPassportFromSupabase(cleanUsername); + if (updatedPassport) { + get().setPassportData(cleanUsername, updatedPassport); + } + + } catch (error) { + console.error('Error in syncSupabaseProfile:', error); + } + } + }), + { + name: 'talent-protocol-cache', + version: 2, + partialize: (state) => ({ + passportCache: state.passportCache + }) + } + ) +); \ No newline at end of file diff --git a/src/utils/browser-api.ts b/src/utils/browser-api.ts new file mode 100644 index 0000000..73e1aac --- /dev/null +++ b/src/utils/browser-api.ts @@ -0,0 +1,65 @@ +export const browserAPI = { + runtime: { + sendMessage: (message: any): Promise => { + if (typeof browser !== 'undefined') { + return browser.runtime.sendMessage(message); + } + return new Promise((resolve) => { + chrome.runtime.sendMessage(message, (response) => { + resolve(response); + }); + }); + }, + + onMessage: { + addListener: (callback: (message: any, sender: any, sendResponse: any) => Promise | void) => { + if (typeof browser !== 'undefined') { + browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + const response = callback(message, sender, sendResponse); + if (response !== null && typeof response === 'object' && 'then' in response) { + response.then(sendResponse); + return true; + } + return false; + }); + } else { + chrome.runtime.onMessage.addListener(callback); + } + } + } + }, + + storage: { + local: { + get: (keys: string | string[] | null): Promise => { + if (typeof browser !== 'undefined') { + return browser.storage.local.get(keys); + } + return new Promise((resolve) => { + chrome.storage.local.get(keys, (result) => { + resolve(result); + }); + }); + }, + + set: (items: { [key: string]: any }): Promise => { + if (typeof browser !== 'undefined') { + return browser.storage.local.set(items); + } + return new Promise((resolve) => { + chrome.storage.local.set(items, () => { + resolve(); + }); + }); + } + } + } +}; + +// Add type definitions for Firefox's browser API +declare global { + interface Window { + browser: typeof chrome; + } + const browser: typeof chrome; +} \ No newline at end of file diff --git a/src/utils/safe-dom.ts b/src/utils/safe-dom.ts new file mode 100644 index 0000000..68733e0 --- /dev/null +++ b/src/utils/safe-dom.ts @@ -0,0 +1,66 @@ +export function createSafeElement(tag: string, attributes: Record = {}, textContent: string = ''): HTMLElement { + const element = document.createElement(tag); + + // Safely set attributes + Object.entries(attributes).forEach(([key, value]) => { + if (key === 'className') { + element.className = value; + } else { + element.setAttribute(key, value); + } + }); + + // Safely set text content + if (textContent) { + element.textContent = textContent; + } + + return element; +} + +export function createSafeImage(src: string, alt: string, className: string = ''): HTMLImageElement { + const img = document.createElement('img'); + img.src = src; + img.alt = alt; + if (className) { + img.className = className; + } + return img; +} + +export function appendChildren(parent: HTMLElement, children: HTMLElement[]): void { + children.forEach(child => parent.appendChild(child)); +} + +export function createSvgElement(svgString: string): SVGElement { + const parser = new DOMParser(); + const doc = parser.parseFromString(svgString, 'image/svg+xml'); + return doc.documentElement as SVGElement; +} + +// Utility for creating verified badge icon +export function createVerifiedIcon(): HTMLElement { + const icon = document.createElement('span'); + icon.className = 'verified-icon'; + icon.textContent = '✓'; + icon.style.cssText = 'font-size: 12px; margin-left: 4px;'; + return icon; +} + +// Safe template creation utility +export function createSafeTemplate(data: Record): HTMLElement { + const container = document.createElement('div'); + + // Create and append elements safely + const titleElement = document.createElement('h3'); + titleElement.textContent = String(data.title || ''); + container.appendChild(titleElement); + + if (data.description) { + const descElement = document.createElement('p'); + descElement.textContent = String(data.description); + container.appendChild(descElement); + } + + return container; +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 4ae5e4e..1283b42 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,7 +4,10 @@ const CopyPlugin = require('copy-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -module.exports = { +module.exports = (env) => { + const browser = env.browser || 'chrome'; + + return { mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', devtool: 'source-map', entry: { @@ -13,7 +16,7 @@ module.exports = { background: './src/background.ts' }, output: { - path: path.resolve(__dirname, 'dist'), + path: path.resolve(__dirname, `dist/${browser}`), filename: '[name].js', clean: true }, @@ -68,6 +71,10 @@ module.exports = { }), new CopyPlugin({ patterns: [ + { + from: `manifest.${browser}.json`, + to: 'manifest.json' + }, { from: 'src/manifest.json' }, { from: 'src/style/global.css' }, { from: 'src/style/styles.css' }, @@ -93,4 +100,5 @@ module.exports = { }), ], }, +} } \ No newline at end of file