git-city-3d-github-visualization by aradotso/trending-skills
npx skills add https://github.com/aradotso/trending-skills --skill git-city-3d-github-visualization技能来自 ara.so — Daily 2026 Skills 合集。
Git City 将 GitHub 个人资料转化为 3D 像素艺术城市。每个用户都成为一座独特的建筑:高度来自贡献数,宽度来自仓库数,窗户亮度来自获得的星标数。使用 Next.js 16(App Router)、React Three Fiber 和 Supabase 构建。
git clone https://github.com/srizzon/git-city.git
cd git-city
npm install
# 复制环境变量模板
cp .env.example .env.local # Linux/macOS
copy .env.example .env.local # Windows CMD
Copy-Item .env.example .env.local # PowerShell
npm run dev
# → http://localhost:3001
复制后填写 .env.local:
# Supabase — 项目设置 → 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 — 设置 → 开发者设置 → 个人访问令牌
GITHUB_TOKEN=github_pat_your_token_here
# 可选:用于访问 /admin/ads 的 GitHub 登录名,用逗号分隔
ADMIN_GITHUB_LOGINS=your_github_login
仪表板 → 项目设置 → API github.com → 设置 → 开发者设置 → 个人访问令牌(推荐使用细粒度令牌)
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
git-city/
├── app/ # Next.js App Router 页面
│ ├── page.tsx # 主城市视图
│ ├── [username]/ # 用户个人资料页面
│ ├── compare/ # 并排比较模式
│ └── admin/ # 管理面板
├── components/
│ ├── city/ # 3D 城市场景组件
│ │ ├── Building.tsx # 单个建筑网格
│ │ ├── CityScene.tsx # 主 R3F 画布/场景
│ │ └── LODManager.tsx # 细节层次系统
│ ├── ui/ # 2D 覆盖层 UI 组件
│ └── profile/ # 个人资料页面组件
├── lib/
│ ├── github.ts # GitHub API 辅助函数
│ ├── supabase/ # Supabase 客户端 + 服务器工具
│ ├── buildings.ts # 建筑指标计算
│ └── achievements.ts # 成就逻辑
├── hooks/ # 自定义 React 钩子
├── types/ # TypeScript 类型定义
└── public/ # 静态资源
建筑根据 GitHub 个人资料数据生成:
// lib/buildings.ts 模式
interface BuildingMetrics {
height: number; // 基于总贡献数
width: number; // 基于公共仓库数量
windowBrightness: number; // 基于获得的总星标数
windowPattern: number[]; // 基于近期活动模式
}
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: [] };
}
// components/city/Building.tsx 模式
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);
// 动画化选中的建筑
useFrame((state) => {
if (meshRef.current && isSelected) {
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime) * 0.05;
}
});
return (
<group position={position} onClick={onClick}>
{/* 建筑主体 */}
<mesh ref={meshRef}>
<boxGeometry args={[metrics.width, metrics.height, metrics.width]} />
<meshStandardMaterial color="#1a1a2e" />
</mesh>
{/* 窗户使用实例化网格以提高性能 */}
<WindowInstances metrics={metrics} />
</group>
);
}
Git City 使用实例化渲染来处理窗户 — 这对于拥有许多建筑的城市至关重要:
// components/city/WindowInstances.tsx 模式
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;
// 根据建筑尺寸计算窗户位置
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>
);
}
// lib/github.ts 模式
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> {
// 使用 GitHub GraphQL 获取贡献日历数据
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;
}
// lib/supabase/server.ts 模式 — 服务器端客户端
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 — 浏览器客户端
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// lib/achievements.ts 模式
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
condition: (stats: UserStats) => boolean;
}
export const ACHIEVEMENTS: Achievement[] = [
{
id: 'first-commit',
name: '首次提交',
description: '完成了你的首次贡献',
icon: '🌱',
condition: (stats) => stats.totalContributions >= 1,
},
{
id: 'thousand-commits',
name: '提交达人',
description: '总贡献数达到 1,000+',
icon: '⚡',
condition: (stats) => stats.totalContributions >= 1000,
},
{
id: 'star-collector',
name: '星标收藏家',
description: '在所有仓库中获得了 100+ 星标',
icon: '⭐',
condition: (stats) => stats.totalStars >= 100,
},
{
id: 'open-sourcer',
name: '开源先锋',
description: '拥有 20+ 个公共仓库',
icon: '📦',
condition: (stats) => stats.publicRepos >= 20,
},
];
export function calculateAchievements(stats: UserStats): Achievement[] {
return ACHIEVEMENTS.filter((achievement) => achievement.condition(stats));
}
// 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>
);
}
// 在装饰注册表中注册
export const DECORATIONS: Decoration[] = [
{
id: 'golden-crown',
slot: 'crown',
name: '金色王冠',
price: 500,
component: CrownDecoration,
},
];
// components/city/CameraController.tsx 模式
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) => {
// 平滑地将相机向目标位置插值
camera.position.lerp(targetRef.current, delta * 2);
});
// 通过上下文或 ref 暴露 flyTo 函数
const flyTo = (position: THREE.Vector3) => {
targetRef.current.copy(position).add(new THREE.Vector3(0, 10, 20));
};
return null;
}
// 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('必须登录才能发送赞赏');
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}`);
}
// app/[username]/page.tsx 模式
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: `在 Git City 中查看 ${params.username} 的建筑`,
openGraph: {
images: [`/api/og/${params.username}`],
},
};
}
// 城市中使用的简化 LOD 模式
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'; // 完整细节 + 动画窗户
if (distance < 150) return 'medium'; // 简化窗户
return 'low'; // 仅显示方框
}
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, // 每 30 秒刷新一次以获取实时活动动态
});
return { buildings: data, error, isLoading };
}
| 路由 | 用途 |
|---|---|
GET /api/buildings | 获取所有城市建筑及其位置 |
GET /api/profile/[username] | GitHub + 城市个人资料数据 |
POST /api/kudos | 向用户发送赞赏 |
GET /api/og/[username] | 生成 OG 分享卡片图片 |
POST /api/webhook/stripe | Stripe 支付 Webhook |
GET /admin/ads | 管理面板(需要 ADMIN_GITHUB_LOGINS) |
3D 场景未渲染
检查 @react-three/fiber 和 three 版本是否兼容。画布需要在其容器 div 上设置高度。
GitHub API 速率限制
使用具有适当范围的细粒度令牌。应用在 Supabase 中缓存 GitHub 响应,以避免重复的 API 调用。
Supabase 身份验证在本地不工作
在 Supabase 项目中配置 GitHub OAuth 提供程序,并确保本地回调 URL(http://localhost:3001/auth/callback)已加入允许列表。
建筑未显示
检查 Supabase 行级安全策略是否允许匿名用户读取 profiles 和 buildings 表。
窗户闪烁/抖动
这通常是 Z-fighting 问题。沿法线轴为窗户网格位置添加微小的偏移量(0.001)。
建筑数量多时性能问题
确保窗户使用实例化网格且 LOD 系统处于活动状态。避免在渲染循环内创建新的 THREE.Material 实例 — 在组件外部定义它们或使用 useMemo。
| 层级 | 技术 |
|---|---|
| 框架 | Next.js 16(App Router, Turbopack) |
| 3D 渲染 | Three.js + @react-three/fiber + drei |
| 数据库 | Supabase(PostgreSQL + RLS) |
| 身份验证 | Supabase GitHub OAuth |
| 支付 | Stripe |
| 样式 | Tailwind CSS v4 + Silkscreen 字体 |
| 托管 | Vercel |
| 许可证 | AGPL-3.0 |
每周安装量
225
仓库
GitHub 星标数
10
首次出现
6 天前
安全审计
安装于
github-copilot224
codex224
warp224
amp224
cline224
kimi-cli224
Skill by ara.so — Daily 2026 Skills collection.
Git City transforms GitHub profiles into a 3D pixel art city. Each user becomes a unique building: height from contributions, width from repos, window brightness from stars. Built with Next.js 16 (App Router), React Three Fiber, and Supabase.
git clone https://github.com/srizzon/git-city.git
cd git-city
npm install
# Copy env template
cp .env.example .env.local # Linux/macOS
copy .env.example .env.local # Windows CMD
Copy-Item .env.example .env.local # PowerShell
npm run dev
# → http://localhost:3001
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)
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
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: [] };
}
// 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>
);
}
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>
);
}
// 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;
}
// 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!
);
}
// 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));
}
// 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,
},
];
// 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;
}
// 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}`);
}
// 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}`],
},
};
}
// 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
}
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 };
}
| Route | Purpose |
|---|---|
GET /api/buildings | Fetch all city buildings with positions |
GET /api/profile/[username] | GitHub + city profile data |
POST /api/kudos | Send kudos to a user |
GET /api/og/[username] | Generate OG share card image |
POST /api/webhook/stripe | Stripe payment webhook |
GET /admin/ads |
3D scene not rendering
Check that @react-three/fiber and three versions are compatible. The canvas needs a height set on its container div.
GitHub API rate limits
Use a fine-grained token with appropriate scopes. The app caches GitHub responses in Supabase to avoid repeated API calls.
Supabase auth not working locally
Configure the GitHub OAuth provider in your Supabase project and ensure your local callback URL (http://localhost:3001/auth/callback) is allowlisted.
Buildings not appearing
Check that Supabase Row Level Security policies allow reads on the profiles and buildings tables for anonymous users.
Window shimmer/flicker
This is usually a Z-fighting issue. Add a tiny offset (0.001) to window mesh positions along the normal axis.
Performance issues with many buildings
Ensure instanced meshes are used for windows and the LOD system is active. Avoid creating new THREE.Material instances inside render loops — define them outside components or use useMemo.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| 3D Rendering | Three.js + @react-three/fiber + drei |
| Database | Supabase (PostgreSQL + RLS) |
| Auth | Supabase GitHub OAuth |
| Payments | Stripe |
| Styling | Tailwind CSS v4 + Silkscreen font |
| Hosting | Vercel |
| License | AGPL-3.0 |
Weekly Installs
225
Repository
GitHub Stars
10
First Seen
6 days ago
Security Audits
Gen Agent Trust HubPassSocketPassSnykWarn
Installed on
github-copilot224
codex224
warp224
amp224
cline224
kimi-cli224
Flutter应用架构设计指南:分层结构、数据层实现与最佳实践
4,000 周安装
Admin panel (requires ADMIN_GITHUB_LOGINS) |