diff --git a/app/api/card/route.jsx b/app/api/card/route.jsx index d6497fa..97a815c 100644 --- a/app/api/card/route.jsx +++ b/app/api/card/route.jsx @@ -4,6 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { classifyAgent, getPowerTier } from '../../../lib/agent-class.js'; import { scoreAll } from '../../../lib/scoring.js'; +import { addDeveloperRanks } from '../../../lib/ranking.js'; export const runtime = 'nodejs'; @@ -12,19 +13,15 @@ const COSMOS_KEY = process.env.COSMOS_KEY; const DATABASE = process.env.COSMOS_DATABASE || 'devglobe'; const CONTAINER = process.env.COSMOS_CONTAINER || 'developers'; -async function getDeveloper(login) { +async function getRankedDevelopers() { if (COSMOS_ENDPOINT && COSMOS_KEY) { try { const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY }); const container = client.database(DATABASE).container(CONTAINER); const { resources } = await container.items.query({ - query: 'SELECT * FROM c WHERE c.login = @login', - parameters: [{ name: '@login', value: login }], + query: 'SELECT c.id, c.login, c.name, c.avatarUrl, c.location, c.followers, c.totalStars, c.totalForks, c.totalWatchers, c.totalCommits, c.topLanguage, c.soReputation, c.soAnswers, c.soAcceptRate, c.soBadges, c.claimed FROM c', }).fetchAll(); - if (resources.length > 0) { - const scored = scoreAll(resources); - return scored[0]; - } + if (resources.length > 0) return addDeveloperRanks(scoreAll(resources)); } catch (err) { console.error('Card: Cosmos error', err.message); } @@ -33,8 +30,12 @@ async function getDeveloper(login) { const filePath = path.join(process.cwd(), 'data', 'developers-sample.json'); const raw = await fs.readFile(filePath, 'utf-8'); const data = JSON.parse(raw); - const scored = scoreAll(data); - return scored.find(d => d.login === login) || null; + return addDeveloperRanks(scoreAll(data)); +} + +async function getDeveloper(login) { + const developers = await getRankedDevelopers(); + return developers.find(d => d.login.toLowerCase() === login.toLowerCase()) || null; } function formatNum(n) { @@ -66,12 +67,32 @@ export async function GET(request) { width: '1200', height: '630', display: 'flex', - background: 'linear-gradient(135deg, #0a0e17 0%, #111827 40%, #0f172a 100%)', + background: 'linear-gradient(135deg, #080b10 0%, #111820 52%, #15120f 100%)', fontFamily: 'Inter, sans-serif', position: 'relative', overflow: 'hidden', }} > + {/* Top identity bar */} +
+
+ DEVGLOBE +
+
+ OPEN SOURCE IDENTITY ยท 2026 +
+
+ {/* Grid pattern overlay */}
{/* Agent icon */} @@ -191,7 +212,7 @@ export async function GET(request) { flexDirection: 'column', justifyContent: 'center', flex: 1, - padding: '40px 60px 40px 20px', + padding: '74px 60px 54px 20px', }} > {/* Agent class header */} @@ -241,13 +262,57 @@ export async function GET(request) { “{agent.tagline}”
+ {/* Global and local rank */} +
+
+
+ #{formatNum(dev.globalRank)} + of {formatNum(dev.globalTotal)} +
+
GLOBAL RANK
+
+ {dev.countryRank && ( +
+
+ #{formatNum(dev.countryRank)} + of {formatNum(dev.countryTotal)} +
+
IN {dev.country.toUpperCase()}
+
+ )} +
+ {/* Score + Tier */}
@@ -325,9 +390,9 @@ export async function GET(request) { flexDirection: 'column', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', - borderRadius: '10', - padding: '12px 20px', - minWidth: '130', + borderRadius: '8', + padding: '10px 14px', + minWidth: '120', }} >
import('../components/Globe.jsx'), { ssr: false }); @@ -27,13 +27,9 @@ export default function Home() { const [claimStatus, setClaimStatus] = useState('unclaimed'); // 'unclaimed' | 'claimed' | 'no_match' const [claimedLogins, setClaimedLogins] = useState(new Set()); const [sidebarOpen, setSidebarOpen] = useState(false); + const [cardRequest, setCardRequest] = useState(0); const globeRef = useRef(null); - const countryRankings = useMemo( - () => buildCountryRankings(developers), - [developers] - ); - useEffect(() => { // Mirrors the blocking script in layout.jsx so React state matches the // theme already applied to before hydration. @@ -93,7 +89,7 @@ export default function Home() { const devRes = await fetch('/api/developers'); if (devRes.ok) { const raw = await devRes.json(); - const scored = scoreAll(raw); + const scored = addDeveloperRanks(scoreAll(raw)); setDevelopers(scored); setFiltered(scored); const claimed = new Set(raw.filter(d => d.claimed).map(d => d.login)); @@ -132,7 +128,7 @@ export default function Home() { const res = await fetch('/api/developers'); if (!res.ok) throw new Error(`Failed to load data: ${res.status}`); const raw = await res.json(); - const scored = scoreAll(raw); + const scored = addDeveloperRanks(scoreAll(raw)); setDevelopers(scored); setFiltered(scored); // Build set of all claimed logins from data @@ -148,15 +144,32 @@ export default function Home() { }, []); const handleSearch = useCallback((results) => { - const scored = scoreAll(results); - setFiltered(scored); - }, []); + const developerByLogin = new Map(developers.map(developer => [developer.login, developer])); + const rankedResults = results.map(result => ({ + ...developerByLogin.get(result.login), + ...result, + })); + setSelectedDev(null); + setCardRequest(0); + setFiltered(rankedResults); + }, [developers]); + + const handleGenerateCard = useCallback((developer) => { + const rankedDeveloper = developers.find(item => item.login === developer.login) || developer; + setSelectedDev(rankedDeveloper); + setCardRequest(request => request + 1); + setSidebarOpen(false); + if (rankedDeveloper.lat != null && rankedDeveloper.lng != null) { + setFlyTarget({ lat: rankedDeveloper.lat, lng: rankedDeveloper.lng }); + } + }, [developers]); const handleResetFilter = useCallback(() => { setFiltered(developers); }, [developers]); const handleSelectDev = useCallback((dev) => { + setCardRequest(0); setSelectedDev(dev); setSidebarOpen(false); if (dev?.lat != null && dev?.lng != null) { @@ -197,6 +210,7 @@ export default function Home() { }, []); const handleHome = useCallback(() => { + setCardRequest(0); setSelectedDev(null); setCompareDevs([]); setFiltered(developers); @@ -226,6 +240,7 @@ export default function Home() { developers={developers} onResults={handleSearch} onReset={handleResetFilter} + onGenerateCard={handleGenerateCard} /> 0} /> )} {compareDevs.length === 2 && ( diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx index cf1fc73..825a2ef 100644 --- a/components/DetailPanel.jsx +++ b/components/DetailPanel.jsx @@ -4,13 +4,17 @@ import React, { useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { formatNum } from '../lib/format.js'; -export default function DetailPanel({ dev, onClose, claimedLogins, countryRankings }) { +export default function DetailPanel({ dev, onClose, claimedLogins, openCardOnMount = false }) { const [fullData, setFullData] = useState(null); const [showCard, setShowCard] = useState(false); const radarRef = useRef(null); const heatmapRef = useRef(null); const langRef = useRef(null); + useEffect(() => { + if (openCardOnMount) setShowCard(true); + }, [openCardOnMount]); + // Fetch full details on mount useEffect(() => { let cancelled = false; @@ -52,7 +56,6 @@ export default function DetailPanel({ dev, onClose, claimedLogins, countryRankin const soAnswers = merged.soAnswers || 0; const soAcceptRate = merged.soAcceptRate || 0; const soBadges = merged.soBadges || 0; - const countryRank = countryRankings?.get(dev.login || dev.id); return (
@@ -77,12 +80,20 @@ export default function DetailPanel({ dev, onClose, claimedLogins, countryRankin
๐Ÿ“ {dev.location || 'Unknown location'}
Score: {dev.score}/100 - {countryRank && ( + {dev.globalRank && ( + + Global #{formatNum(dev.globalRank)} + + )} + {dev.countryRank && ( - #{countryRank.countryRank} in {countryRank.country} + #{formatNum(dev.countryRank)} in {dev.country} )}
@@ -163,7 +174,7 @@ export default function DetailPanel({ dev, onClose, claimedLogins, countryRankin {/* Card Modal */} {showCard && ( - setShowCard(false)} /> + setShowCard(false)} /> )}
); @@ -373,14 +384,17 @@ function renderLanguages(container, languages) { /* โ”€โ”€โ”€ Card Modal โ”€โ”€โ”€ */ -function CardModal({ login, name, onClose }) { +function CardModal({ dev, onClose }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(false); + const { login } = dev; + const name = dev.name || login; const cardUrl = `/api/card?login=${encodeURIComponent(login)}`; const fullCardUrl = typeof window !== 'undefined' ? `${window.location.origin}${cardUrl}` : cardUrl; const siteUrl = typeof window !== 'undefined' ? window.location.origin : ''; - const shareText = `Check out my DevAgent card on DevGlobe! ๐ŸŒ๐Ÿค– Ranked among 26K+ open-source developers.`; + const rankText = dev.globalRank ? `Global #${dev.globalRank} of ${dev.globalTotal}` : 'Ranked on DevGlobe'; + const shareText = `My DevGlobe developer card: ${rankText}.`; const shareLinks = { twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(siteUrl)}`, @@ -409,7 +423,28 @@ function CardModal({ login, name, onClose }) {
e.stopPropagation()}> -

Your DevAgent Card

+
+
+
DEVGLOBE IDENTITY
+

{name}'s Developer Card

+
+ {dev.globalRank && ( + + )} +
+ + {dev.globalRank && ( +
+
#{formatNum(dev.globalRank)}Global rank
+ {dev.countryRank &&
#{formatNum(dev.countryRank)}in {dev.country}
} +
+ )}
{loading && !error &&
Generating card...
} diff --git a/components/SearchBar.jsx b/components/SearchBar.jsx index b7d000d..9ac15be 100644 --- a/components/SearchBar.jsx +++ b/components/SearchBar.jsx @@ -1,7 +1,6 @@ 'use client'; import React, { useState, useRef, useCallback } from 'react'; -import { scoreAll } from '../lib/scoring.js'; const SAMPLES_BY_MODE = { text: [ @@ -24,12 +23,13 @@ const SAMPLES_BY_MODE = { ], }; -export default function SearchBar({ developers, onResults, onReset }) { +export default function SearchBar({ developers, onResults, onReset, onGenerateCard }) { const [query, setQuery] = useState(''); const [mode, setMode] = useState('text'); const [topN, setTopN] = useState(50); const [searching, setSearching] = useState(false); const [resultCount, setResultCount] = useState(null); + const [singleResult, setSingleResult] = useState(null); const inputRef = useRef(null); const abortRef = useRef(null); const timerRef = useRef(null); @@ -38,11 +38,12 @@ export default function SearchBar({ developers, onResults, onReset }) { if (!q.trim()) { onReset(); setResultCount(null); + setSingleResult(null); return; } if (m === 'text') { - const lower = q.toLowerCase(); + const lower = q.trim().toLowerCase(); const results = developers.filter(d => (d.login && d.login.toLowerCase().includes(lower)) || (d.name && d.name.toLowerCase().includes(lower)) || @@ -50,6 +51,7 @@ export default function SearchBar({ developers, onResults, onReset }) { ); onResults(results); setResultCount(results.length); + setSingleResult(results.length === 1 ? results[0] : null); return; } @@ -68,6 +70,10 @@ export default function SearchBar({ developers, onResults, onReset }) { const results = data.results || []; onResults(results); setResultCount(results.length); + const matchedDeveloper = results.length === 1 + ? developers.find(developer => developer.login === results[0].login) || results[0] + : null; + setSingleResult(matchedDeveloper); } } catch (e) { if (e.name !== 'AbortError') console.error('Search failed:', e); @@ -79,6 +85,7 @@ export default function SearchBar({ developers, onResults, onReset }) { const handleInput = (e) => { const val = e.target.value; setQuery(val); + setSingleResult(null); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => doSearch(val, mode), 400); }; @@ -114,6 +121,7 @@ export default function SearchBar({ developers, onResults, onReset }) { const handleClear = () => { setQuery(''); setResultCount(null); + setSingleResult(null); onReset(); inputRef.current?.focus(); }; @@ -156,11 +164,33 @@ export default function SearchBar({ developers, onResults, onReset }) {
{resultCount !== null && query && ( -
- {resultCount === 0 ? 'No results found' : `Top ${resultCount} developer${resultCount !== 1 ? 's' : ''} matched`} - +
+
+ {resultCount === 0 ? 'No developers found' : `${resultCount} developer${resultCount !== 1 ? 's' : ''} found`} + +
+ {singleResult && ( +
+ +
+ {singleResult.name || singleResult.login} + + @{singleResult.login} + {singleResult.globalRank ? ` ยท Global #${singleResult.globalRank}` : ''} + +
+ +
+ )}
)}
diff --git a/lib/ranking.js b/lib/ranking.js new file mode 100644 index 0000000..b047adf --- /dev/null +++ b/lib/ranking.js @@ -0,0 +1,33 @@ +import { countryKey, extractCountry, normalizeCountry } from './country.js'; + +export function addDeveloperRanks(developers) { + const countryGroups = new Map(); + + developers.forEach((developer) => { + if (!developer.location) return; + const country = normalizeCountry(extractCountry(developer.location)); + const key = countryKey(country); + if (!key) return; + + if (!countryGroups.has(key)) countryGroups.set(key, { country, logins: [] }); + countryGroups.get(key).logins.push(developer.login); + }); + + const countryRanks = new Map(); + countryGroups.forEach(({ country, logins }) => { + logins.forEach((login, index) => { + countryRanks.set(login, { + country, + countryRank: index + 1, + countryTotal: logins.length, + }); + }); + }); + + return developers.map((developer, index) => ({ + ...developer, + globalRank: index + 1, + globalTotal: developers.length, + ...(countryRanks.get(developer.login) || {}), + })); +} \ No newline at end of file diff --git a/lib/rankings.js b/lib/rankings.js deleted file mode 100644 index 582dd1e..0000000 --- a/lib/rankings.js +++ /dev/null @@ -1,39 +0,0 @@ -import { extractCountry, normalizeCountry, countryKey } from './country.js'; - -/** - * Build per-developer country ranks from existing composite scores. - * Returns Map. - */ -export function buildCountryRankings(developers) { - const byCountry = new Map(); - - for (const dev of developers) { - if (!dev.location) continue; - - const country = normalizeCountry(extractCountry(dev.location)); - const key = countryKey(country); - if (!key || country.length <= 1) continue; - - if (!byCountry.has(key)) { - byCountry.set(key, { name: country, devs: [] }); - } - byCountry.get(key).devs.push(dev); - } - - const byLogin = new Map(); - - for (const { name, devs } of byCountry.values()) { - devs.sort((a, b) => b.score - a.score); - const total = devs.length; - devs.forEach((dev, index) => { - const login = dev.login || dev.id; - byLogin.set(login, { - country: name, - countryRank: index + 1, - countryTotal: total, - }); - }); - } - - return byLogin; -} diff --git a/styles/main.css b/styles/main.css index 2973186..e3dae65 100644 --- a/styles/main.css +++ b/styles/main.css @@ -346,15 +346,24 @@ body { to { transform: rotate(360deg); } } +.search-bar__feedback { + width: min(100%, 520px); + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel-90); + backdrop-filter: blur(16px); + box-shadow: var(--shadow-strong); +} + .search-bar__results { font-size: 11px; color: var(--text-muted); - background: var(--bg-panel-70); - padding: 3px 10px; - border-radius: 10px; display: flex; align-items: center; + justify-content: space-between; gap: 8px; + padding: 0 2px; } .search-bar__reset { @@ -748,6 +757,12 @@ body { font-weight: 600; } +.rank-badge--global { + background: rgba(8, 145, 178, 0.12); + border: 1px solid rgba(8, 145, 178, 0.4); + color: #22d3ee; +} + .rank-badge--country { background: rgba(251, 191, 36, 0.15); border: 1px solid rgba(251, 191, 36, 0.45); @@ -1787,10 +1802,65 @@ body { } .card-modal__title { - font-size: 18px; - font-weight: 600; + font-size: 20px; + font-weight: 700; color: var(--text-primary); - margin-bottom: 20px; + margin: 2px 0 0; +} + +.card-modal__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + padding-right: 32px; +} + +.card-modal__eyebrow { + color: #0891b2; + font-size: 10px; + font-weight: 800; + letter-spacing: 1.5px; +} + +.card-modal__rank-info { + flex: 0 0 auto; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--overlay-subtle); + color: var(--text-secondary); + font: 700 13px var(--font); + cursor: help; +} + +.card-modal__rank-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.card-modal__rank-strip > div { + display: flex; + align-items: baseline; + gap: 8px; + padding: 10px 14px; + border: 1px solid rgba(8, 145, 178, 0.25); + border-radius: 8px; + background: rgba(8, 145, 178, 0.08); +} + +.card-modal__rank-strip strong { + color: #0891b2; + font-size: 20px; +} + +.card-modal__rank-strip span { + color: var(--text-secondary); + font-size: 12px; } .card-modal__preview { @@ -1918,4 +1988,65 @@ body { .card-modal__social--reddit:hover { background: rgba(255, 69, 0, 0.12); border-color: rgba(255, 69, 0, 0.4); +} + +.search-bar__card-suggestion { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + margin-top: 8px; + padding: 9px; + border-top: 1px solid var(--border); +} + +.search-bar__card-suggestion > img { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--border-light); + object-fit: cover; +} + +.search-bar__card-identity { + display: flex; + min-width: 0; + flex-direction: column; +} + +.search-bar__card-identity strong, +.search-bar__card-identity span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-bar__card-identity strong { + color: var(--text-primary); + font-size: 12px; +} + +.search-bar__card-identity span { + margin-top: 2px; + color: var(--text-muted); + font-size: 10px; +} + +.search-bar__card-suggestion > button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border: 1px solid rgba(8, 145, 178, 0.4); + border-radius: 6px; + background: rgba(8, 145, 178, 0.14); + color: #22d3ee; + font: 700 11px var(--font); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.search-bar__card-suggestion > button:hover { + border-color: #0891b2; + background: rgba(8, 145, 178, 0.24); } \ No newline at end of file