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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Local development defaults
NEXT_PUBLIC_BASE_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:8080

# Production reference
# NEXT_PUBLIC_BASE_URL=https://www.git-ranker.com
# NEXT_PUBLIC_API_URL=https://www.git-ranker.com

# Optional analytics / error tracking
# NEXT_PUBLIC_ANALYTICS_ENDPOINT=
# NEXT_PUBLIC_SENTRY_DSN=
# SENTRY_DSN=
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
ENV NEXT_TELEMETRY_DISABLED=1

RUN test -n "$NEXT_PUBLIC_API_URL" || (echo "NEXT_PUBLIC_API_URL build arg is required" >&2 && exit 1)
RUN test -n "$NEXT_PUBLIC_BASE_URL" || (echo "NEXT_PUBLIC_BASE_URL build arg is required" >&2 && exit 1)

RUN npm run build

# Stage 3: Production runner
Expand Down
72 changes: 50 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,64 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# git-ranker-client

## Getting Started
`git-ranker-client`는 Git Ranker의 Next.js 16 프런트엔드다. 랭킹 조회, 사용자 상세, 로그인 진입, SEO 메타데이터와 배지 링크를 제공한다.

First, run the development server:
## Requirements

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
- Node.js 20+
- npm
- `NEXT_PUBLIC_BASE_URL`
- `NEXT_PUBLIC_API_URL`

`NEXT_PUBLIC_BASE_URL`와 `NEXT_PUBLIC_API_URL`는 build와 runtime 모두에서 필수다. 값이 없으면 `npm run build`와 런타임 초기화가 즉시 실패한다.

## Environment

로컬 Next.js 실행은 `.env.local`, Docker Compose 실행은 `.env`를 사용하면 된다. 시작점으로는 [.env.example](.env.example)을 복사한다.

로컬 개발 예시:

```env
NEXT_PUBLIC_BASE_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:8080
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
프로덕션 예시:

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```env
NEXT_PUBLIC_BASE_URL=https://www.git-ranker.com
NEXT_PUBLIC_API_URL=https://www.git-ranker.com
```

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
선택 env:

## Learn More
- `NEXT_PUBLIC_ANALYTICS_ENDPOINT`
- `NEXT_PUBLIC_SENTRY_DSN`
- `SENTRY_DSN`

To learn more about Next.js, take a look at the following resources:
선택 env가 없어도 핵심 기능은 동작한다. 없으면 analytics/web vitals 전송이나 Sentry 수집만 비활성화된다.

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Commands

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
```bash
npm install
npm run dev
npm run lint
npx tsc --noEmit
npm run build
```

## Docker Compose

```bash
cp .env.example .env
docker compose up --build
```

## Deploy on Vercel
`docker-compose.yml`과 `Dockerfile`은 `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_API_URL`가 비어 있으면 즉시 실패한다.

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Build And Runtime Notes

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- `JetBrains Mono`는 공식 JetBrains Mono release v2.304에서 가져온 로컬 자산을 사용한다.
- 폰트 라이선스는 [src/fonts/JetBrainsMono-OFL.txt](src/fonts/JetBrainsMono-OFL.txt)에 보관한다.
- locale routing과 보안 헤더는 [src/proxy.ts](src/proxy.ts)에서 처리한다.
- public URL 정책은 [src/shared/lib/public-env.ts](src/shared/lib/public-env.ts)에서 단일 기준으로 관리한다.
8 changes: 4 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ services:
dockerfile: Dockerfile
args:
# Build-time args (NEXT_PUBLIC_* are inlined during build)
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL is required}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:?NEXT_PUBLIC_BASE_URL is required}
restart: unless-stopped
ports:
- "3000:3000"
Expand All @@ -16,8 +16,8 @@ services:
NODE_ENV: production
TZ: Asia/Seoul
# Runtime vars (for middleware/server-side code)
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL is required}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:?NEXT_PUBLIC_BASE_URL is required}
deploy:
resources:
limits:
Expand Down
10 changes: 5 additions & 5 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type {Metadata, Viewport} from "next";
import localFont from "next/font/local";
import { JetBrains_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css";
import QueryProvider from "@/shared/providers/query-provider";
Expand All @@ -12,6 +11,7 @@ import { WebVitalsReporter } from "@/shared/components/web-vitals-reporter";
import { cn } from "@/shared/lib/utils";
import { LocaleProvider } from "@/shared/providers/locale-provider";
import { getRequestLocale } from "@/shared/i18n/server-locale";
import { publicBaseUrl } from "@/shared/lib/public-env";

const pretendard = localFont({
src: "../fonts/PretendardVariable.woff2",
Expand All @@ -20,13 +20,14 @@ const pretendard = localFont({
variable: "--font-sans",
});

const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
const jetbrainsMono = localFont({
src: "../fonts/JetBrainsMonoVariable.ttf",
variable: "--font-mono",
display: "swap",
weight: "100 800",
});

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com"
const BASE_URL = publicBaseUrl

export async function generateMetadata(): Promise<Metadata> {
const locale = await getRequestLocale()
Expand Down Expand Up @@ -105,7 +106,6 @@ export default async function RootLayout({
{/* Preconnect to external origins for faster resource loading */}
<link rel="preconnect" href="https://avatars.githubusercontent.com" />
<link rel="dns-prefetch" href="https://avatars.githubusercontent.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-QKZNEY525E"
strategy="afterInteractive"
Expand Down
3 changes: 2 additions & 1 deletion src/app/login/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Metadata } from "next"
import { getRequestLocale } from "@/shared/i18n/server-locale"
import { publicBaseUrl } from "@/shared/lib/public-env"

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com"
const BASE_URL = publicBaseUrl

export async function generateMetadata(): Promise<Metadata> {
const locale = await getRequestLocale()
Expand Down
3 changes: 2 additions & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { cn } from "@/shared/lib/utils"
import { LiveTicker, TickerUpdate } from "@/shared/components/ui/live-ticker"
import { GithubIcon } from "@/shared/components/icons/github-icon"
import { useI18n } from "@/shared/providers/locale-provider"
import { githubOAuthStartUrl } from "@/shared/lib/public-env"

// [Data] Action 필드 제거 (User + Tier)
const MOCK_LIVE_UPDATES: TickerUpdate[] = [
Expand Down Expand Up @@ -458,7 +459,7 @@ export default function LoginPage() {
const privacyPolicy = locale === "ko" ? PRIVACY_POLICY_KO : PRIVACY_POLICY_EN

const handleGithubLogin = () => {
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/oauth2/authorization/github`
window.location.href = githubOAuthStartUrl
}

const containerVariants = {
Expand Down
3 changes: 2 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { Metadata } from "next"
import { HeroSection } from "@/features/home/components/hero-section"
import { GithubIcon } from "@/shared/components/icons/github-icon"
import { getRequestLocale } from "@/shared/i18n/server-locale"
import { publicBaseUrl } from "@/shared/lib/public-env"

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com"
const BASE_URL = publicBaseUrl

export async function generateMetadata(): Promise<Metadata> {
const locale = await getRequestLocale()
Expand Down
3 changes: 2 additions & 1 deletion src/app/ranking/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Metadata } from "next"
import { getRequestLocale } from "@/shared/i18n/server-locale"
import { publicBaseUrl } from "@/shared/lib/public-env"

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com"
const BASE_URL = publicBaseUrl

export async function generateMetadata(): Promise<Metadata> {
const locale = await getRequestLocale()
Expand Down
3 changes: 2 additions & 1 deletion src/app/robots.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MetadataRoute } from "next";
import { publicBaseUrl } from "@/shared/lib/public-env";

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com";
const BASE_URL = publicBaseUrl;

export default function robots(): MetadataRoute.Robots {
return {
Expand Down
6 changes: 3 additions & 3 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { MetadataRoute } from "next"
import type { ApiResponse, RankingUserInfo } from "@/shared/types/api"
import { isTier } from "@/shared/types/api"
import { publicApiBaseUrl, publicBaseUrl } from "@/shared/lib/public-env"

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 BASE_URL = publicBaseUrl
const SEO_LOCALES = ["en", "ko"] as const

type SitemapRankingPage = {
Expand Down Expand Up @@ -53,7 +53,7 @@ const extractRankingPage = (payload: unknown): SitemapRankingPage | null => {
}

async function getRankingPage(page: number): Promise<SitemapRankingPage | null> {
const response = await fetch(`${API_URL}/api/v1/ranking?page=${page}&size=20`, {
const response = await fetch(`${publicApiBaseUrl}/ranking?page=${page}&size=20`, {
next: { revalidate: 3600 },
headers: {
Accept: "application/json",
Expand Down
4 changes: 2 additions & 2 deletions src/app/users/[username]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ImageResponse } from 'next/og'
import type { ApiResponse, Tier } from '@/shared/types/api'
import { isTier } from '@/shared/types/api'
import { getPublicApiUrl } from '@/shared/lib/public-env'

export const runtime = 'edge'
export const alt = 'Git Ranker Profile'
Expand Down Expand Up @@ -71,12 +72,11 @@ const extractUserData = (payload: unknown): OgUserData | 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: OgUserData | null = null

try {
const response = await fetch(`${apiUrl}/api/v1/users/${username}`, {
const response = await fetch(getPublicApiUrl(`/api/v1/users/${username}`), {
next: { revalidate: 3600 }
})
if (response.ok) {
Expand Down
3 changes: 2 additions & 1 deletion src/app/users/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next"
import { getUser } from "@/features/user/api/user-service"
import { getRequestLocale } from "@/shared/i18n/server-locale"
import { publicBaseUrl } from "@/shared/lib/public-env"
import { UserProfileClient } from "./user-profile-client"

// ISR: revalidate every 1 hour
Expand All @@ -14,7 +15,7 @@ interface PageProps {
params: Promise<{ username: string }>
}

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com"
const BASE_URL = publicBaseUrl

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { username } = await params
Expand Down
15 changes: 11 additions & 4 deletions src/app/users/[username]/user-profile-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import { getErrorMessage } from "@/shared/lib/api-client"
import { useEffect, useState } from "react"
import { TIER_STYLES } from "@/shared/constants/tier-styles"
import { useI18n } from "@/shared/providers/locale-provider"
import { localizePathname } from "@/shared/i18n/config"
import { getBadgeImageUrl, getPublicSiteUrl, githubOAuthStartUrl } from "@/shared/lib/public-env"

interface UserProfileClientProps {
username: string
}

export function UserProfileClient({ username }: UserProfileClientProps) {
const { t } = useI18n()
const { t, locale } = useI18n()
const router = useRouter()
const { data: user, isLoading, isError } = useUser(username)
const refreshMutation = useRefreshUser()
Expand Down Expand Up @@ -81,8 +83,13 @@ export function UserProfileClient({ username }: UserProfileClientProps) {
}

const handleCopyBadge = () => {
const badgeUrl = `${process.env.NEXT_PUBLIC_API_URL || 'https://www.git-ranker.com'}/api/v1/badges/${user?.nodeId}`
const markdown = `[![Git Ranker](${badgeUrl})](https://www.git-ranker.com)`
if (!user?.nodeId) {
return
}

const badgeUrl = getBadgeImageUrl(user.nodeId)
const profileUrl = getPublicSiteUrl(localizePathname(`/users/${encodeURIComponent(user.username)}`, locale))
const markdown = `[![Git Ranker](${badgeUrl})](${profileUrl})`
navigator.clipboard.writeText(markdown)
toast.success(t("profile.badge.copied"))
}
Expand Down Expand Up @@ -110,7 +117,7 @@ export function UserProfileClient({ username }: UserProfileClientProps) {
}

const handleGithubRegister = () => {
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/oauth2/authorization/github`
window.location.href = githubOAuthStartUrl
}

if (isLoading) {
Expand Down
7 changes: 4 additions & 3 deletions src/features/user/components/badge-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { useI18n } from "@/shared/providers/locale-provider"
import { localizePathname } from "@/shared/i18n/config"
import { getBadgeImageUrl, getPublicSiteUrl } from "@/shared/lib/public-env"

interface BadgeGeneratorProps {
nodeId: string
Expand Down Expand Up @@ -50,9 +51,9 @@ export function BadgeGenerator({ nodeId, username }: BadgeGeneratorProps) {
const { t, locale } = useI18n()
const [copied, setCopied] = useState<CopyType>(null)

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://www.git-ranker.com"
const badgeUrl = `${process.env.NEXT_PUBLIC_API_URL || 'https://www.git-ranker.com'}/api/v1/badges/${nodeId}`
const profileUrl = `${baseUrl}${localizePathname(`/users/${username}`, locale)}`
const badgeUrl = getBadgeImageUrl(nodeId)
const profilePath = localizePathname(`/users/${encodeURIComponent(username)}`, locale)
const profileUrl = getPublicSiteUrl(profilePath)

const markdownCode = `[![Git Ranker](${badgeUrl})](${profileUrl})`
const htmlCode = `<a href="${profileUrl}"><img src="${badgeUrl}" alt="Git Ranker Badge" /></a>`
Expand Down
Loading
Loading