diff --git a/src/app/api/user/connections/route.ts b/src/app/api/user/connections/route.ts new file mode 100644 index 00000000..0390af61 --- /dev/null +++ b/src/app/api/user/connections/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { createServerSupabase } from "@/lib/supabase-server"; +import { getSupabaseAdmin } from "@/lib/supabase"; +import { fetchFollowers, fetchFollowing } from "@/lib/github-api"; + +export async function GET() { + const supabase = await createServerSupabase(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const githubLogin = ( + user.user_metadata.user_name ?? + user.user_metadata.preferred_username ?? + "" + ).toLowerCase(); + + if (!githubLogin) { + return NextResponse.json({ error: "No GitHub login found in session" }, { status: 400 }); + } + + try { + // Fetch followers and following from GitHub in parallel + const [followers, following] = await Promise.all([ + fetchFollowers(githubLogin), + fetchFollowing(githubLogin), + ]); + + const uniqueLogins = Array.from(new Set([...followers, ...following])); + + if (uniqueLogins.length === 0) { + return NextResponse.json({ connections: [] }); + } + + const admin = getSupabaseAdmin(); + + // Query the database to see which of these users are already in Git City + const { data: devs, error } = await admin + .from("developers") + .select("id, github_login, name, avatar_url, contributions, total_stars, public_repos, primary_language, rank, claimed, kudos_count, visit_count, contributions_total, contribution_years, total_prs, total_reviews, repos_contributed_to, followers, following, organizations_count, account_created_at, current_streak, active_days_last_year, language_diversity, app_streak, rabbit_completed, district, district_chosen, xp_total, xp_level") + .in("github_login", uniqueLogins); + + if (error) { + console.error("Error fetching connections from DB:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json({ connections: devs ?? [] }); + } catch (err) { + console.error("Error in connections API:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8d7eea36..ff06e2c7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -446,6 +446,7 @@ function HomeContent() { const [stats, setStats] = useState({ total_developers: 0, total_contributions: 0 }); const [milestoneCelebrations, setMilestoneCelebrations] = useState<{ milestone: number; reached_at: string }[]>([]); const [focusedBuilding, setFocusedBuilding] = useState(null); + const [connections, setConnections] = useState([]); const [shareData, setShareData] = useState<{ login: string; contributions: number; @@ -716,7 +717,7 @@ function HomeContent() { }) ); }) - .catch(() => {}); + .catch(() => { }); return () => { cancelled = true; }; }, [session, buildings]); @@ -750,7 +751,7 @@ function HomeContent() { const admin = !!authLogin && adminLogins.includes(authLogin); setIsAdmin(admin); if (admin) { - fetch("/api/items").then(r => r.json()).then(d => setDropPlantItems(d.items ?? [])).catch(() => {}); + fetch("/api/items").then(r => r.json()).then(d => setDropPlantItems(d.items ?? [])).catch(() => { }); } }, [authLogin]); @@ -799,6 +800,20 @@ function HomeContent() { .catch(() => { }); }, [sessionUserId]); + // Fetch user connections (followers/following) + useEffect(() => { + if (!sessionUserId) { + setConnections([]); + return; + } + fetch("/api/user/connections") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.connections) setConnections(data.connections); + }) + .catch(() => { }); + }, [sessionUserId]); + // Cycle theme: save to localStorage + sync to DB if logged in const cycleTheme = useCallback(() => { setThemeIndex((i) => { @@ -1262,7 +1277,7 @@ function HomeContent() { rawDevsRef.current = allDevs; setStats(cityStats); - const layout = generateCityLayout(allDevs); + const layout = generateCityLayout(allDevs, connections, authLogin); // Decode obfuscated drops (_d) and merge into buildings by rank if (dropsPayload.length > 0) { @@ -1284,7 +1299,14 @@ function HomeContent() { setDistrictZones(layout.districtZones); setCityCache({ ...layout, stats: cityStats, rawDevs: rawDevsRef.current }); return layout.buildings; - }, []); + }, [connections, authLogin]); + + // Regenerate city layout when connections or auth change (after initial load) + useEffect(() => { + if (loadStage === "done") { + reloadCity(); + } + }, [connections, authLogin, reloadCity, loadStage]); // Handle loading fade complete: transition to "done" and trigger intro const handleLoadFadeComplete = useCallback(() => { @@ -1396,7 +1418,7 @@ function HomeContent() { rawDevsRef.current = allDevs; setStats(cityStats); - const finalLayout = generateCityLayout(allDevs); + const finalLayout = generateCityLayout(allDevs, connections, authLogin); // Decode obfuscated drops (_d) and merge into buildings by rank if (dropsPayload.length > 0) { @@ -1550,7 +1572,7 @@ function HomeContent() { xp_level: devData.xp_level ?? 1, }; rawDevsRef.current = [...rawDevsRef.current, newDev]; - const layout = generateCityLayout(rawDevsRef.current); + const layout = generateCityLayout(rawDevsRef.current, connections, authLogin); setBuildings(layout.buildings); setPlazas(layout.plazas); setDecorations(layout.decorations); @@ -1602,7 +1624,7 @@ function HomeContent() { : prev ); }) - .catch(() => {}); + .catch(() => { }); } } else { // Buildings array was replaced (full layout loaded) — keep selectedBuilding in sync @@ -1633,7 +1655,7 @@ function HomeContent() { b.login.toLowerCase() === authLogin ? { ...b, claimed: true } : b )); }) - .catch(() => {}); + .catch(() => { }); } return; } @@ -1672,7 +1694,7 @@ function HomeContent() { xp_level: devData.xp_level ?? 1, }; rawDevsRef.current = [...rawDevsRef.current, newDev]; - const layout = generateCityLayout(rawDevsRef.current); + const layout = generateCityLayout(rawDevsRef.current, connections, authLogin); setBuildings(layout.buildings); setPlazas(layout.plazas); setDecorations(layout.decorations); @@ -1857,7 +1879,7 @@ function HomeContent() { ) : [...rawDevsRef.current, syncedDev]; - const layout = generateCityLayout(rawDevsRef.current); + const layout = generateCityLayout(rawDevsRef.current, connections, authLogin); setBuildings(layout.buildings); setPlazas(layout.plazas); setDecorations(layout.decorations); @@ -2321,7 +2343,7 @@ function HomeContent() { onSponsorClick={(slug) => { trackLandmarkClicked(slug); const adId = getLandmarkAdId(slug); - if (adId) fetch("/api/sky-ads/track", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ad_id: adId, event_type: "click" }) }).catch(() => {}); + if (adId) fetch("/api/sky-ads/track", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ad_id: adId, event_type: "click" }) }).catch(() => { }); if (!exploreMode) setExploreMode(true); setActiveSponsor(slug); setSelectedBuilding(null); diff --git a/src/lib/github-api.ts b/src/lib/github-api.ts index a62260d2..552cc784 100644 --- a/src/lib/github-api.ts +++ b/src/lib/github-api.ts @@ -342,3 +342,33 @@ export async function fetchGitHubDeveloperData( } : {}), }; } + +/** + * Fetch a user's followers from GitHub. + * Returns up to 100 logins. + */ +export async function fetchFollowers(login: string): Promise { + const headers = ghHeaders(); + const res = await fetch( + `https://api.github.com/users/${encodeURIComponent(login)}/followers?per_page=100`, + { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) } + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data.map((u: any) => u.login.toLowerCase()) : []; +} + +/** + * Fetch the users a user is following from GitHub. + * Returns up to 100 logins. + */ +export async function fetchFollowing(login: string): Promise { + const headers = ghHeaders(); + const res = await fetch( + `https://api.github.com/users/${encodeURIComponent(login)}/following?per_page=100`, + { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) } + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data.map((u: any) => u.login.toLowerCase()) : []; +} diff --git a/src/lib/github.ts b/src/lib/github.ts index 03b3254e..a4f53452 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -225,12 +225,12 @@ function calcHeightV2( const cnsScore = Math.pow(consistencyNorm, 0.6); const composite = - cScore * 0.35 + - sScore * 0.20 + + cScore * 0.35 + + sScore * 0.20 + prScore * 0.15 + extScore * 0.10 + cnsScore * 0.10 + - fScore * 0.10; + fScore * 0.10; const height = Math.min(MAX_BUILDING_HEIGHT, MIN_BUILDING_HEIGHT + composite * HEIGHT_RANGE); return { height, composite }; @@ -380,7 +380,11 @@ function localBlockAxisPos(idx: number, footprint: number): number { return sign * (abs * footprint + abs * STREET_W); } -export function generateCityLayout(devs: DeveloperRecord[]): { +export function generateCityLayout( + devs: DeveloperRecord[], + neighbors: DeveloperRecord[] = [], + centerLogin?: string, +): { buildings: CityBuilding[]; plazas: CityPlaza[]; decorations: CityDecoration[]; @@ -392,12 +396,26 @@ export function generateCityLayout(devs: DeveloperRecord[]): { const plazas: CityPlaza[] = []; const decorations: CityDecoration[] = []; const districtZones: DistrictZone[] = []; - const maxContrib = devs.reduce((max, d) => Math.max(max, d.contributions), 1); - const maxStars = devs.reduce((max, d) => Math.max(max, d.total_stars), 1); - const maxContribV2 = devs.reduce((max, d) => Math.max(max, d.contributions_total ?? 0), 1); + // ── 0. Pre-process for Neighborhood ── + const neighborSet = new Set(neighbors.map(n => n.github_login.toLowerCase())); + const neighborhoodSet = new Set(neighborSet); + if (centerLogin) neighborhoodSet.add(centerLogin.toLowerCase()); + + // Merge neighbors into the layout pool if they aren't already there + const allDevsForLayout = [...devs]; + const existingLogins = new Set(devs.map(d => d.github_login.toLowerCase())); + for (const n of neighbors) { + if (!existingLogins.has(n.github_login.toLowerCase())) { + allDevsForLayout.push(n); + } + } + + const maxContrib = allDevsForLayout.reduce((max, d) => Math.max(max, d.contributions), 1); + const maxStars = allDevsForLayout.reduce((max, d) => Math.max(max, d.total_stars), 1); + const maxContribV2 = allDevsForLayout.reduce((max, d) => Math.max(max, d.contributions_total ?? 0), 1); // ── 1. Group by district, sort within each, concat in priority order ── - const composites = precomputeComposites(devs, maxContrib, maxStars, maxContribV2); + const composites = precomputeComposites(allDevsForLayout, maxContrib, maxStars, maxContribV2); const DISTRICT_ORDER = [ 'backend', 'frontend', 'fullstack', 'data_ai', 'devops', @@ -405,7 +423,7 @@ export function generateCityLayout(devs: DeveloperRecord[]): { ]; const districtGroups: Record = {}; - for (const dev of devs) { + for (const dev of allDevsForLayout) { const did = dev.district ?? inferDistrict(dev.primary_language); if (!districtGroups[did]) districtGroups[did] = []; districtGroups[did].push(dev); @@ -424,34 +442,41 @@ export function generateCityLayout(devs: DeveloperRecord[]): { // ── Extract top 50 global devs as "downtown" (center, around the spire) ── const DOWNTOWN_COUNT = 50; const LOTS_PER_BLOCK = BLOCK_SIZE * BLOCK_SIZE; // 16 - const allDevsSorted = [...devs].sort((a, b) => + const allDevsSorted = [...allDevsForLayout].sort((a, b) => (composites.get(b.github_login) ?? 0) - (composites.get(a.github_login) ?? 0) ); const downtownDevs = allDevsSorted.slice(0, DOWNTOWN_COUNT); - const downtownSet = new Set(downtownDevs.map(d => d.github_login)); - for (let i = 0; i < downtownDevs.length; i += LOTS_PER_BLOCK) { - const end = Math.min(i + LOTS_PER_BLOCK, downtownDevs.length); - const slice = downtownDevs.slice(i, end); - const shuffled = seededShuffle(slice, hashStr('downtown') + i); - for (let j = 0; j < shuffled.length; j++) downtownDevs[i + j] = shuffled[j]; - } + // Neighborhood exclusion: remove neighborhood from standard pools + const filteredDowntownDevs = downtownDevs.filter(d => !neighborhoodSet.has(d.github_login.toLowerCase())); + const downtownSet = new Set(filteredDowntownDevs.map(d => d.github_login)); - const downtownOverride = new Set(downtownDevs.map(d => d.github_login)); + const downtownOverride = new Set(filteredDowntownDevs.map(d => d.github_login)); + + // neighborhoodDevs: specific users for (0,0) cluster + const centerDev = centerLogin ? allDevsForLayout.find(d => d.github_login.toLowerCase() === centerLogin.toLowerCase()) : null; + const neighborhoodDevs: DeveloperRecord[] = []; + if (centerDev) neighborhoodDevs.push(centerDev); + for (const n of neighbors) { + if (n.github_login.toLowerCase() !== centerLogin?.toLowerCase()) { + neighborhoodDevs.push(n); + } + } + if (neighborhoodDevs.length > 0) neighborhoodDevs.forEach(d => downtownOverride.add(d.github_login)); // ── Per-district dev arrays (sorted by composite, block-shuffled, minus downtown) ── const districtDevArrays: { did: string; devs: DeveloperRecord[] }[] = []; for (const did of DISTRICT_ORDER) { const group = districtGroups[did]; if (!group || group.length === 0) continue; - const filtered = group.filter(d => !downtownSet.has(d.github_login)); + const filtered = group.filter(d => !downtownSet.has(d.github_login) && !neighborhoodSet.has(d.github_login.toLowerCase())); if (filtered.length === 0) continue; // Full shuffle: organic mix of tall and short buildings districtDevArrays.push({ did, devs: seededShuffle(filtered, hashStr(did)) }); } for (const [did, group] of Object.entries(districtGroups)) { if (!DISTRICT_ORDER.includes(did)) { - const filtered = group.filter(d => !downtownSet.has(d.github_login)); + const filtered = group.filter(d => !downtownSet.has(d.github_login) && !neighborhoodSet.has(d.github_login.toLowerCase())); if (filtered.length === 0) continue; districtDevArrays.push({ did, devs: seededShuffle(filtered, hashStr(did)) }); } @@ -674,8 +699,12 @@ export function generateCityLayout(devs: DeveloperRecord[]): { // Sponsored landmarks (dynamic) for (const s of SPONSORS) occupiedCells.add(`${s.gridX},${s.gridZ}`); - // ── A) Downtown: spiral at grid (0, 0) ── - placeSpiralCluster(downtownDevs, 0, 0, true); + // ── A) Neighborhood + Downtown: spiral at grid (0, 0) ── + if (neighborhoodDevs.length > 0) { + placeSpiralCluster(neighborhoodDevs, 0, 0, true); + } + // Standard Downtown remaining devs + placeSpiralCluster(filteredDowntownDevs, 0, 0, neighborhoodDevs.length === 0); // ── B) Districts: spiral at offset grid positions ── for (let di = 0; di < districtDevArrays.length; di++) {