Skip to content
Open
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
55 changes: 55 additions & 0 deletions src/app/api/user/connections/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
44 changes: 33 additions & 11 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ function HomeContent() {
const [stats, setStats] = useState<CityStats>({ total_developers: 0, total_contributions: 0 });
const [milestoneCelebrations, setMilestoneCelebrations] = useState<{ milestone: number; reached_at: string }[]>([]);
const [focusedBuilding, setFocusedBuilding] = useState<string | null>(null);
const [connections, setConnections] = useState<DeveloperRecord[]>([]);
const [shareData, setShareData] = useState<{
login: string;
contributions: number;
Expand Down Expand Up @@ -716,7 +717,7 @@ function HomeContent() {
})
);
})
.catch(() => {});
.catch(() => { });
return () => { cancelled = true; };
}, [session, buildings]);

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1602,7 +1624,7 @@ function HomeContent() {
: prev
);
})
.catch(() => {});
.catch(() => { });
}
} else {
// Buildings array was replaced (full layout loaded) — keep selectedBuilding in sync
Expand Down Expand Up @@ -1633,7 +1655,7 @@ function HomeContent() {
b.login.toLowerCase() === authLogin ? { ...b, claimed: true } : b
));
})
.catch(() => {});
.catch(() => { });
}
return;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions src/lib/github-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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<string[]> {
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()) : [];
}
73 changes: 51 additions & 22 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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[];
Expand All @@ -392,20 +396,34 @@ 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',
'mobile', 'gamedev', 'vibe_coder', 'creator', 'security',
];

const districtGroups: Record<string, DeveloperRecord[]> = {};
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);
Expand All @@ -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)) });
}
Expand Down Expand Up @@ -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++) {
Expand Down