Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,28 +1,38 @@
# GitHub Personal Access Token (required)
# GitHub API and nomination enrichment
# Required for dataset scripts and complete self-nomination details.
# Create at: https://github.com/settings/tokens
# Scopes needed: read:user, read:org
# Classic token scopes: read:user, read:org
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# StackOverflow API Key (optional, increases rate limit)
# Azure Cosmos DB
# Required in production and for nomination/review scripts.
COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/
COSMOS_KEY=your_cosmos_primary_or_secondary_key
COSMOS_DATABASE=devglobe
COSMOS_CONTAINER=developers

# Azure OpenAI vector and hybrid search (optional)
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
AZURE_OPENAI_KEY=your_azure_openai_key
EMBEDDING_DEPLOYMENT=text-embedding-3-small

# Stack Overflow API (optional; increases the request limit)
# Register at: https://stackapps.com/apps/oauth/register
SO_API_KEY=your_stackoverflow_api_key

# Geocoding API Key (OpenCage)
# OpenCage geocoding (optional; OpenStreetMap is the fallback)
# Register at: https://opencagedata.com/api
GEOCODE_API_KEY=your_opencage_api_key

# GitHub OAuth (for "Claim your profile" feature)
# GitHub OAuth for sign-in and profile claiming
# Create at: https://github.com/settings/developers -> OAuth Apps
GITHUB_CLIENT_ID=your_github_oauth_client_id
GITHUB_CLIENT_SECRET=your_github_oauth_client_secret

# Session secret (used to sign JWT session cookies)
# Session cookie signing secret (required in production)
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=your_random_session_secret

# Canonical public URL used by metadata, structured data, cards, robots, and sitemap.
# Canonical public URL for metadata, cards, robots, sitemap, and share links.
# Include the https:// protocol and do not wrap the value in quotes.
NEXT_PUBLIC_SITE_URL=https://devglobe.dev

# Base URL of the deployed app (used for OAuth redirect)
NEXT_PUBLIC_BASE_URL=http://localhost:3000
NEXT_PUBLIC_SITE_URL=https://www.devglobe.dev
118 changes: 118 additions & 0 deletions app/api/card/social/route.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { ImageResponse } from '@vercel/og';
import { CosmosClient } from '@azure/cosmos';
import { promises as fs } from 'fs';
import path from 'path';

export const runtime = 'nodejs';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
const DATABASE = process.env.COSMOS_DATABASE || 'devglobe';
const CONTAINER = process.env.COSMOS_CONTAINER || 'developers';
const cosmosClient = COSMOS_ENDPOINT && COSMOS_KEY
? new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY })
: null;
const cosmosContainer = cosmosClient?.database(DATABASE).container(CONTAINER);

async function getDeveloper(login) {
if (cosmosContainer) {
try {
const { resources } = await cosmosContainer.items.query({
query: `SELECT TOP 1 c.login, c.name, c.location, c.followers, c.totalStars, c.totalCommits, c.topLanguage
FROM c
WHERE (c.login = @login OR c.id = @login)
AND (NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved')`,
parameters: [{ name: '@login', value: login }],
}).fetchAll();
if (resources[0]) return resources[0];
} catch (error) {
console.error('Social card: Cosmos error', error.message);
}
}

const filePath = path.join(process.cwd(), 'data', 'developers-sample.json');
const developers = JSON.parse(await fs.readFile(filePath, 'utf-8'));
return developers.find(developer =>
developer.login?.toLowerCase() === login.toLowerCase() || developer.id === login
) || null;
}

function formatNumber(value) {
const number = Number(value) || 0;
if (number >= 1000000) return `${(number / 1000000).toFixed(1)}M`;
if (number >= 1000) return `${(number / 1000).toFixed(1)}K`;
return String(number);
}

export async function GET(request) {
const login = new URL(request.url).searchParams.get('login');
if (!login) return new Response('Missing login parameter', { status: 400 });

const developer = await getDeveloper(login);
if (!developer) return new Response('Developer not found', { status: 404 });

const name = developer.name || developer.login;
const stats = [
{ label: 'GITHUB STARS', value: formatNumber(developer.totalStars) },
{ label: 'COMMITS', value: formatNumber(developer.totalCommits) },
{ label: 'FOLLOWERS', value: formatNumber(developer.followers) },
];

return new ImageResponse(
(
<div
style={{
width: '1200',
height: '630',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '58px 68px',
background: '#0a0f18',
color: '#f8fafc',
fontFamily: 'sans-serif',
position: 'relative',
overflow: 'hidden',
}}
>
<div style={{ position: 'absolute', inset: '0', display: 'flex', border: '12px solid #111c2c' }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '24', fontWeight: '800' }}>
<div style={{ width: '14', height: '14', display: 'flex', borderRadius: '50%', background: '#22d3ee' }} />
DEVGLOBE
</div>
<div style={{ display: 'flex', color: '#94a3b8', fontSize: '18' }}>OPEN SOURCE DEVELOPER</div>
</div>

<div style={{ display: 'flex', flexDirection: 'column', maxWidth: '980px' }}>
<div style={{ display: 'flex', color: '#22d3ee', fontSize: '24', fontWeight: '700', marginBottom: '10px' }}>
@{developer.login}
</div>
<div style={{ display: 'flex', fontSize: '64', fontWeight: '800', lineHeight: '1.05' }}>{name}</div>
<div style={{ display: 'flex', color: '#94a3b8', fontSize: '24', marginTop: '16px' }}>
{[developer.topLanguage, developer.location].filter(Boolean).join(' / ') || 'Developer on DevGlobe'}
</div>
</div>

<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: '52px' }}>
{stats.map(stat => (
<div key={stat.label} style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<div style={{ display: 'flex', color: '#f8fafc', fontSize: '34', fontWeight: '800' }}>{stat.value}</div>
<div style={{ display: 'flex', color: '#64748b', fontSize: '14', fontWeight: '700' }}>{stat.label}</div>
</div>
))}
</div>
<div style={{ display: 'flex', color: '#fbbf24', fontSize: '18', fontWeight: '700' }}>EXPLORE ON DEVGLOBE</div>
</div>
</div>
),
{
width: 1200,
height: 630,
headers: {
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
},
}
);
}
2 changes: 1 addition & 1 deletion app/share/[login]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export async function generateMetadata({ params }) {
const title = `@${login}'s Developer Card | DevGlobe`;
const description = `Explore @${login}'s open-source developer identity, global rank, and impact on DevGlobe.`;
const pageUrl = `${siteUrl}/share/${encodedLogin}`;
const imageUrl = `${siteUrl}/api/card?login=${encodedLogin}&v=${SOCIAL_PREVIEW_VERSION}`;
const imageUrl = `${siteUrl}/api/card/social?login=${encodedLogin}&v=${SOCIAL_PREVIEW_VERSION}`;

return {
title,
Expand Down
81 changes: 76 additions & 5 deletions components/Globe.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ const labelText = d => d.login;
const labelSize = d => 0.6 + (d.score / 100) * 0.4;
const labelColor = () => 'rgba(226, 232, 240, 0.75)';
const noLabel = () => '';
const avatarAltitude = d => pointAltitude(d) + 0.035;
const avatarAltitude = () => 0.018;
const avatarLat = d => d.markerLat;
const avatarLng = d => d.markerLng;

function createAvatarMarker(developer, onSelectDev, setAutoRotate) {
const marker = document.createElement('div');
Expand Down Expand Up @@ -108,6 +110,19 @@ function mainRing(geometry) {
return best;
}

function pointInRing(lng, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [lngI, latI] = ring[i];
const [lngJ, latJ] = ring[j];
if (((latI > lat) !== (latJ > lat)) &&
lng < ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI) {
inside = !inside;
}
}
return inside;
}

// Centroid of a country plus a camera altitude that roughly frames it
function countryView(feat) {
const ring = mainRing(feat?.geometry);
Expand Down Expand Up @@ -150,6 +165,38 @@ function countryView(feat) {
return { lat, lng, altitude: Math.min(2.2, Math.max(0.55, span / 40)) };
}

function countryMarkerPosition(feat) {
const ring = mainRing(feat?.geometry);
const view = countryView(feat);
if (!ring || !view) return null;
if (pointInRing(view.lng, view.lat, ring)) return view;

let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
ring.forEach(([lng, lat]) => {
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLng = Math.min(minLng, lng);
maxLng = Math.max(maxLng, lng);
});

let best = null;
let bestDistance = Infinity;
for (let row = 1; row < 20; row++) {
const lat = minLat + ((maxLat - minLat) * row) / 20;
for (let column = 1; column < 20; column++) {
const lng = minLng + ((maxLng - minLng) * column) / 20;
if (!pointInRing(lng, lat, ring)) continue;
const distance = (lat - view.lat) ** 2 + (lng - view.lng) ** 2;
if (distance < bestDistance) {
best = { lat, lng };
bestDistance = distance;
}
}
}

return best ? { ...best, altitude: view.altitude } : { lat: ring[0][1], lng: ring[0][0], altitude: view.altitude };
}

const Globe = forwardRef(function Globe({
developers,
flyTarget,
Expand Down Expand Up @@ -184,8 +231,32 @@ const Globe = forwardRef(function Globe({
return geoDevs.filter(d => d.score >= 80);
}, [geoDevs]);

// Keep the people-first markers readable and inexpensive at the global view.
const avatarDevs = useMemo(() => geoDevs.slice(0, selectedCountry ? 80 : 40), [geoDevs, selectedCountry]);
// Show one top developer per represented country and use country geometry rather
// than unreliable profile geocodes for the avatar's visual anchor.
const avatarDevs = useMemo(() => {
if (countryFeatures.length === 0) return [];

const featureByCountry = new Map(
countryFeatures.map(feature => [countryKey(featureName(feature)), feature])
);
const representedCountries = new Set();
const markers = [];
const limit = selectedCountry ? 1 : 40;

for (const developer of geoDevs) {
const key = countryKey(extractCountry(developer.location));
if (!key || representedCountries.has(key)) continue;

const position = countryMarkerPosition(featureByCountry.get(key));
if (!position) continue;

representedCountries.add(key);
markers.push({ ...developer, markerLat: position.lat, markerLng: position.lng });
if (markers.length >= limit) break;
}

return markers;
}, [countryFeatures, geoDevs, selectedCountry]);

// Pulsing rings for top 10 developers
const ringsData = useMemo(() => {
Expand Down Expand Up @@ -437,8 +508,8 @@ const Globe = forwardRef(function Globe({
pointColor={pointColor}
pointResolution={6}
htmlElementsData={avatarDevs}
htmlLat={devLat}
htmlLng={devLng}
htmlLat={avatarLat}
htmlLng={avatarLng}
htmlAltitude={avatarAltitude}
htmlElement={avatarElement}
htmlTransitionDuration={250}
Expand Down
Loading
Loading