SKILL.md
$27
npm run dev
→ http://localhost:3001
## Environment Variables
Fill in `.env.local` after copying:
Supabase — Project Settings → API
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
GitHub — Settings → Developer settings → Personal access tokens
GITHUB_TOKEN=github_pat_your_token_here
Optional: comma-separated GitHub logins for /admin/ads access
ADMIN_GITHUB_LOGINS=your_github_login
**Finding Supabase values:** Dashboard → Project Settings → API
**Finding GitHub token:** github.com → Settings → Developer settings → Personal access tokens (fine-grained recommended)
## Project Structure
git-city/
├── app/ # Next.js App Router pages
│ ├── page.tsx # Main city view
│ ├── [username]/ # User profile pages
│ ├── compare/ # Side-by-side compare mode
│ └── admin/ # Admin panel
├── components/
│ ├── city/ # 3D city scene components
│ │ ├── Building.tsx # Individual building mesh
│ │ ├── CityScene.tsx # Main R3F canvas/scene
│ │ └── LODManager.tsx # Level-of-detail system
│ ├── ui/ # 2D overlay UI components
│ └── profile/ # Profile page components
├── lib/
│ ├── github.ts # GitHub API helpers
│ ├── supabase/ # Supabase client + server utils
│ ├── buildings.ts # Building metric calculations
│ └── achievements.ts # Achievement logic
├── hooks/ # Custom React hooks
├── types/ # TypeScript type definitions
└── public/ # Static assets
## Core Concepts
### Building Metrics Mapping
Buildings are generated from GitHub profile data:
// lib/buildings.ts pattern
interface BuildingMetrics {
height: number; // Based on total contributions
width: number; // Based on public repo count
windowBrightness: number; // Based on total stars received
windowPattern: number[]; // Based on recent activity pattern
}
function calculateBuildingMetrics(profile: GitHubProfile): BuildingMetrics {
const height = Math.log10(profile.totalContributions + 1) * 10;
const width = Math.min(Math.ceil(profile.publicRepos / 10), 8);
const windowBrightness = Math.min(profile.totalStars / 1000, 1);
return { height, width, windowBrightness, windowPattern: [] };
}
### 3D Building Component (React Three Fiber)
// components/city/Building.tsx pattern
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface BuildingProps {
position: [number, number, number];
metrics: BuildingMetrics;
username: string;
isSelected?: boolean;
onClick?: () => void;
}
export function Building({ position, metrics, username, isSelected, onClick }: BuildingProps) {
const meshRef = useRef<THREE.Mesh>(null);
// Animate selected building
useFrame((state) => {
if (meshRef.current && isSelected) {
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime) * 0.05;
}
});
return (
<group position={position} onClick={onClick}>
{/ Main building body /}
<mesh ref={meshRef}>
<boxGeometry args={[metrics.width, metrics.height, metrics.width]} />
<meshStandardMaterial color="#1a1a2e" />
</mesh>
{/ Windows as instanced meshes for performance /}
<WindowInstances metrics={metrics} />
</group>
);
}
### Instanced Meshes for Performance
Git City uses instanced rendering for windows — critical for a city with many buildings:
// components/city/WindowInstances.tsx pattern
import { useRef, useEffect } from 'react';
import { InstancedMesh, Matrix4, Color } from 'three';
export function WindowInstances({ metrics }: { metrics: BuildingMetrics }) {
const meshRef = useRef<InstancedMesh>(null);
useEffect(() => {
if (!meshRef.current) return;
const matrix = new Matrix4();
const color = new Color();
let index = 0;
// Calculate window positions based on building dimensions
for (let floor = 0; floor < metrics.height; floor++) {
for (let col = 0; col < metrics.width; col++) {
const isLit = metrics.windowPattern[index] > 0.5;
matrix.setPosition(col 1.1 - metrics.width / 2, floor 1.2, 0.51);
meshRef.current.setMatrixAt(index, matrix);
meshRef.current.setColorAt(
index,
color.set(isLit ? '#FFD700' : '#1a1a2e')
);
index++;
}
}
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}, [metrics]);
const windowCount = Math.floor(metrics.height) * metrics.width;
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, windowCount]}>
<planeGeometry args={[0.4, 0.5]} />
<meshBasicMaterial />
</instancedMesh>
);
}
### GitHub API Integration
// lib/github.ts pattern
import { Octokit } from '@octokit/rest';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
export async function fetchGitHubProfile(username: string) {
const [userResponse, reposResponse] = await Promise.all([
octokit.users.getByUsername({ username }),
octokit.repos.listForUser({ username, per_page: 100, sort: 'updated' }),
]);
const totalStars = reposResponse.data.reduce(
(sum, repo) => sum + (repo.stargazers_count ?? 0),
0
);
return {
username: userResponse.data.login,
avatarUrl: userResponse.data.avatar_url,
publicRepos: userResponse.data.public_repos,
followers: userResponse.data.followers,
totalStars,
};
}
export async function fetchContributionData(username: string): Promise<number> {
// Use GitHub GraphQL for contribution calendar data
const query =
query($username: String!) {
user(login: $username) {
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
contributionCount
date
}
}
}
}
}
}
;
const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: Bearer ${process.env.GITHUB_TOKEN},
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables: { username } }),
});
const data = await response.json();
return data.data.user.contributionsCollection.contributionCalendar.totalContributions;
}
### Supabase Integration
// lib/supabase/server.ts pattern — server-side client
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}
// lib/supabase/client.ts — browser client
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
### Achievement System
// lib/achievements.ts pattern
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
condition: (stats: UserStats) => boolean;
}
export const ACHIEVEMENTS: Achievement[] = [
{
id: 'first-commit',
name: 'First Commit',
description: 'Made your first contribution',
icon: '🌱',
condition: (stats) => stats.totalContributions >= 1,
},
{
id: 'thousand-commits',
name: 'Commit Crusher',
description: '1,000+ total contributions',
icon: '⚡',
condition: (stats) => stats.totalContributions >= 1000,
},
{
id: 'star-collector',
name: 'Star Collector',
description: 'Earned 100+ stars across repos',
icon: '⭐',
condition: (stats) => stats.totalStars >= 100,
},
{
id: 'open-sourcer',
name: 'Open Sourcer',
description: '20+ public repositories',
icon: '📦',
condition: (stats) => stats.publicRepos >= 20,
},
];
export function calculateAchievements(stats: UserStats): Achievement[] {
return ACHIEVEMENTS.filter((achievement) => achievement.condition(stats));
}
### Adding a New Building Decoration
// types/decorations.ts
export type DecorationSlot = 'crown' | 'aura' | 'roof' | 'face';
export interface Decoration {
id: string;
slot: DecorationSlot;
name: string;
price: number;
component: React.ComponentType<DecorationProps>;
}
// components/city/decorations/Crown.tsx
export function CrownDecoration({ position, buildingWidth }: DecorationProps) {
return (
<group position={[position[0], position[1], position[2]]}>
<mesh>
<coneGeometry args={[buildingWidth / 3, 2, 4]} />
<meshStandardMaterial color="#FFD700" metalness={0.8} roughness={0.2} />
</mesh>
</group>
);
}
// Register in decoration registry
export const DECORATIONS: Decoration[] = [
{
id: 'golden-crown',
slot: 'crown',
name: 'Golden Crown',
price: 500,
component: CrownDecoration,
},
];
### Camera / Flight Controls
// components/city/CameraController.tsx pattern
import { useThree, useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import * as THREE from 'three';
export function CameraController() {
const { camera } = useThree();
const targetRef = useRef(new THREE.Vector3());
const velocityRef = useRef(new THREE.Vector3());
useFrame((_, delta) => {
// Smooth lerp camera toward target
camera.position.lerp(targetRef.current, delta * 2);
});
// Expose flyTo function via context or ref
const flyTo = (position: THREE.Vector3) => {
targetRef.current.copy(position).add(new THREE.Vector3(0, 10, 20));
};
return null;
}
### Server Actions (Next.js App Router)
// app/actions/kudos.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export async function sendKudos(toUsername: string) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Must be logged in to send kudos');
const { error } = await supabase.from('kudos').insert({
from_user_id: user.id,
to_username: toUsername,
created_at: new Date().toISOString(),
});
if (error) throw error;
revalidatePath(/${toUsername});
}
### Profile Page Route
// app/[username]/page.tsx pattern
import { fetchGitHubProfile } from '@/lib/github';
import { createClient } from '@/lib/supabase/server';
import { calculateAchievements } from '@/lib/achievements';
import { BuildingPreview } from '@/components/profile/BuildingPreview';
interface Props {
params: { username: string };
}
export default async function ProfilePage({ params }: Props) {
const { username } = params;
const [githubProfile, supabase] = await Promise.all([
fetchGitHubProfile(username),
createClient(),
]);
const { data: cityProfile } = await supabase
.from('profiles')
.select(', decorations()')
.eq('username', username)
.single();
const achievements = calculateAchievements({
totalContributions: githubProfile.totalContributions,
totalStars: githubProfile.totalStars,
publicRepos: githubProfile.publicRepos,
});
return (
<main>
<BuildingPreview profile={githubProfile} cityProfile={cityProfile} />
<AchievementGrid achievements={achievements} />
</main>
);
}
export async function generateMetadata({ params }: Props) {
return {
title: ${params.username} — Git City,
description: View ${params.username}'s building in Git City,
openGraph: {
images: [/api/og/${params.username}],
},
};
}
## Common Development Patterns
### LOD (Level of Detail) System
// Simplified LOD pattern used in the city
import { useThree } from '@react-three/fiber';
export function useLOD(buildingPosition: THREE.Vector3) {
const { camera } = useThree();
const distance = camera.position.distanceTo(buildingPosition);
if (distance < 50) return 'high'; // Full detail + animated windows
if (distance < 150) return 'medium'; // Simplified windows
return 'low'; // Box only
}
### Fetching with SWR in City View
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function useCityBuildings() {
const { data, error, isLoading } = useSWR('/api/buildings', fetcher, {
refreshInterval: 30000, // refresh every 30s for live activity feed
});
return { buildings: data, error, isLoading };
}