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
3 changes: 2 additions & 1 deletion src/app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Loader2, AlertCircle, RefreshCcw } from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import Link from "next/link"
import { useI18n } from "@/shared/providers/locale-provider"
import type { AuthMeResponse } from "@/shared/types/api"

function RedirectHandler() {
const { t } = useI18n()
Expand Down Expand Up @@ -46,7 +47,7 @@ function RedirectHandler() {
hasCalledRef.current = true

// 쿠키는 이미 Set-Cookie로 설정되어 있으므로, /auth/me로 사용자 식별 후 전체 정보 조회
apiClient.get<void, { username: string }>('/auth/me')
apiClient.get<void, AuthMeResponse>('/auth/me')
.then(({ username }) => getUser(username))
.then((user) => {
login(user)
Expand Down
3 changes: 2 additions & 1 deletion src/app/oauth2/redirect/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Loader2, AlertCircle, RefreshCcw } from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import Link from "next/link"
import { useI18n } from "@/shared/providers/locale-provider"
import type { AuthMeResponse } from "@/shared/types/api"

function RedirectHandler() {
const { t } = useI18n()
Expand Down Expand Up @@ -46,7 +47,7 @@ function RedirectHandler() {
hasCalledRef.current = true

// 쿠키는 이미 Set-Cookie로 설정되어 있으므로, /auth/me로 사용자 식별 후 전체 정보 조회
apiClient.get<void, { username: string }>('/auth/me')
apiClient.get<void, AuthMeResponse>('/auth/me')
.then(({ username }) => getUser(username))
.then((user) => {
login(user)
Expand Down
12 changes: 5 additions & 7 deletions src/app/ranking/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState, Suspense, useRef } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { LazyMotion, domAnimation, m } from "framer-motion"
import { useRankingList } from "@/features/ranking/api/ranking-service"
import { Tier } from "@/shared/types/api"
import { Tier, TIER_VALUES } from "@/shared/types/api"
import { getTierBadgeStyle, getTierDotColor } from "@/shared/constants/tier-styles"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/avatar"
import { Skeleton } from "@/shared/components/skeleton"
Expand All @@ -15,10 +15,7 @@ import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, Crown,
import { cn } from "@/shared/lib/utils"
import { useI18n } from "@/shared/providers/locale-provider"

const TIERS: (Tier | 'ALL')[] = [
'ALL', 'CHALLENGER', 'MASTER', 'DIAMOND', 'EMERALD',
'PLATINUM', 'GOLD', 'SILVER', 'BRONZE', 'IRON'
]
const TIER_FILTERS: ReadonlyArray<Tier | 'ALL'> = ['ALL', ...TIER_VALUES]

// [Component] Toolbar
function RankingToolbar({
Expand All @@ -32,7 +29,7 @@ function RankingToolbar({
<div className="sticky top-16 z-40 bg-background/80 backdrop-blur-xl border-b border-border/40 py-4 mb-8 transition-all">
<div className="container max-w-5xl px-4 flex justify-center">
<div className="flex flex-wrap gap-2 justify-center">
{TIERS.map((tier) => (
{TIER_FILTERS.map((tier) => (
<button
key={tier}
onClick={() => onTierChange(tier)}
Expand Down Expand Up @@ -84,7 +81,8 @@ function RankingContent() {
const pageParam = searchParams.get('page')
const page = pageParam ? Math.max(0, parseInt(pageParam, 10) - 1) : 0
const tierParam = searchParams.get('tier')
const selectedTier: Tier | 'ALL' = tierParam && TIERS.includes(tierParam as Tier) ? tierParam as Tier : 'ALL'
const selectedTier: Tier | 'ALL' =
tierParam && TIER_VALUES.includes(tierParam as Tier) ? (tierParam as Tier) : 'ALL'



Expand Down
105 changes: 70 additions & 35 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,93 @@
import { MetadataRoute } from "next"
import type { ApiResponse, RankingUserInfo } from "@/shared/types/api"
import { isTier } from "@/shared/types/api"

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com"
const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://www.git-ranker.com"
const SEO_LOCALES = ["en", "ko"] as const

interface RankingUser {
username: string
tier: string
totalScore: number
profileImage: string
}

interface ApiResponse {
rankings?: RankingUser[]
pageInfo?: {
type SitemapRankingPage = {
rankings: RankingUserInfo[]
pageInfo: {
totalPages: number
totalElements: number
}
}

async function getTopUsers(): Promise<RankingUser[]> {
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null

const isRankingUserInfo = (value: unknown): value is RankingUserInfo =>
isObjectRecord(value) &&
typeof value.username === "string" &&
typeof value.profileImage === "string" &&
typeof value.ranking === "number" &&
typeof value.totalScore === "number" &&
typeof value.tier === "string" &&
isTier(value.tier)

const isSitemapRankingPage = (value: unknown): value is SitemapRankingPage =>
isObjectRecord(value) &&
Array.isArray(value.rankings) &&
value.rankings.every(isRankingUserInfo) &&
isObjectRecord(value.pageInfo) &&
typeof value.pageInfo.totalPages === "number" &&
typeof value.pageInfo.totalElements === "number"

const extractRankingPage = (payload: unknown): SitemapRankingPage | null => {
if (!isObjectRecord(payload)) {
return null
}

if ("result" in payload) {
return payload.result === "SUCCESS" && isSitemapRankingPage(payload.data)
? payload.data
: null
}

if ("success" in payload) {
return isSitemapRankingPage(payload.success) ? payload.success : null
}

return isSitemapRankingPage(payload) ? payload : null
}

async function getRankingPage(page: number): Promise<SitemapRankingPage | null> {
const response = await fetch(`${API_URL}/api/v1/ranking?page=${page}&size=20`, {
next: { revalidate: 3600 },
headers: {
Accept: "application/json",
},
})

if (!response.ok) {
return null
}

const payload = await response.json() as ApiResponse<SitemapRankingPage> | SitemapRankingPage | { success?: SitemapRankingPage }
return extractRankingPage(payload)
}

async function getTopUsers(): Promise<RankingUserInfo[]> {
try {
// First, get the first page to understand total pages
const firstPageResponse = await fetch(`${API_URL}/api/v1/ranking?page=0&size=20`, {
next: { revalidate: 3600 },
headers: {
'Accept': 'application/json',
}
})

if (!firstPageResponse.ok) {
const firstPageData = await getRankingPage(0)
if (!firstPageData) {
return []
}

const firstPageData: ApiResponse = await firstPageResponse.json()
const totalPages = Math.min(firstPageData.pageInfo?.totalPages || 1, 25) // Max 25 pages = 500 users
const totalPages = Math.min(firstPageData.pageInfo.totalPages || 1, 25) // Max 25 pages = 500 users

// Fetch up to 500 users (25 pages x 20 users per page)
const pages = Array.from({ length: totalPages }, (_, i) => i)
const responses = await Promise.all(
pages.map(page =>
fetch(`${API_URL}/api/v1/ranking?page=${page}&size=20`, {
next: { revalidate: 3600 },
headers: {
'Accept': 'application/json',
}
}).then(res => res.ok ? res.json() as Promise<ApiResponse> : null)
.catch(() => null)
)
)
const pages = Array.from({ length: Math.max(totalPages - 1, 0) }, (_, i) => i + 1)
const responses = [
firstPageData,
...(await Promise.all(pages.map((page) => getRankingPage(page).catch(() => null)))),
]

const users: RankingUser[] = []
const users: RankingUserInfo[] = []
for (const response of responses) {
if (response?.rankings) {
if (response) {
users.push(...response.rankings)
}
}
Expand Down
70 changes: 55 additions & 15 deletions src/app/users/[username]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { ImageResponse } from 'next/og'
import type { ApiResponse, Tier } from '@/shared/types/api'
import { isTier } from '@/shared/types/api'

export const runtime = 'edge'
export const alt = 'Git Ranker Profile'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

interface UserData {
const tierColors: Record<Tier, string> = {
CHALLENGER: '#dc2626',
MASTER: '#7c3aed',
DIAMOND: '#0ea5e9',
EMERALD: '#10b981',
PLATINUM: '#06b6d4',
GOLD: '#eab308',
SILVER: '#64748b',
BRONZE: '#ea580c',
IRON: '#71717a',
}

type OgUserData = {
username: string
tier: string
tier: Tier
totalScore: number
ranking: number
percentile: number
Expand All @@ -19,36 +33,61 @@ interface UserData {
mergedPrCount: number
}

const tierColors: Record<string, string> = {
CHALLENGER: '#dc2626',
MASTER: '#7c3aed',
DIAMOND: '#0ea5e9',
PLATINUM: '#06b6d4',
GOLD: '#eab308',
SILVER: '#64748b',
BRONZE: '#ea580c',
IRON: '#71717a',
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null

const isOgUserData = (value: unknown): value is OgUserData =>
isObjectRecord(value) &&
typeof value.username === 'string' &&
typeof value.tier === 'string' &&
isTier(value.tier) &&
typeof value.totalScore === 'number' &&
typeof value.ranking === 'number' &&
typeof value.percentile === 'number' &&
typeof value.profileImage === 'string' &&
typeof value.commitCount === 'number' &&
typeof value.prCount === 'number' &&
typeof value.issueCount === 'number' &&
typeof value.reviewCount === 'number' &&
typeof value.mergedPrCount === 'number'

const extractUserData = (payload: unknown): OgUserData | null => {
if (!isObjectRecord(payload)) {
return null
}

if ('result' in payload) {
return payload.result === 'SUCCESS' && isOgUserData(payload.data)
? payload.data
: null
}

if ('success' in payload) {
return isOgUserData(payload.success) ? payload.success : null
}

return isOgUserData(payload) ? payload : null
}

export default async function Image({ params }: { params: Promise<{ username: string }> }) {
const { username } = await params
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://www.git-ranker.com'

let user: UserData | null = null
let user: OgUserData | null = null

try {
const response = await fetch(`${apiUrl}/api/v1/users/${username}`, {
next: { revalidate: 3600 }
})
if (response.ok) {
const data = await response.json()
user = data.data || data.success
const payload = await response.json() as ApiResponse<OgUserData> | OgUserData | { success?: OgUserData }
user = extractUserData(payload)
}
} catch {
// Fall back to default
}

const accent = tierColors[user?.tier || ''] || '#6366f1'
const accent = user ? tierColors[user.tier] ?? '#6366f1' : '#6366f1'

// User not found fallback
if (!user) {
Expand Down Expand Up @@ -145,6 +184,7 @@ export default async function Image({ params }: { params: Promise<{ username: st
}}>
<img
src={user.profileImage}
alt={`${user.username} avatar`}
width={80}
height={80}
style={{
Expand Down
Loading
Loading