From 0d5136d003537704f6bd600defbda27cf7ffdc5c Mon Sep 17 00:00:00 2001 From: harry Date: Mon, 12 Aug 2024 10:26:40 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20SVG=20radius=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/generateSvg.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/generateSvg.ts b/api/generateSvg.ts index 78f415f..66215af 100644 --- a/api/generateSvg.ts +++ b/api/generateSvg.ts @@ -30,16 +30,17 @@ export function generateSvg(stats: Tstats): string { - + - Top + TOP ${stats.rankPercentage}% Dreamhack wargame stats ${stats.nickname} - + + Solved ${stats.wargame_solved} From 0195948f2665a3163b94ed2e4286a5790c29d812 Mon Sep 17 00:00:00 2001 From: harry Date: Mon, 12 Aug 2024 11:06:38 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20TOP=20N%=EA=B0=80=20overallTopPerc?= =?UTF-8?q?entage=EB=A1=9C=20=EC=A0=81=EC=9A=A9=EB=90=98=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: overallTopPercentage가 아닌, wargameTopPercentage로 변경 --- api/generateSvg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/generateSvg.ts b/api/generateSvg.ts index 66215af..ff70cac 100644 --- a/api/generateSvg.ts +++ b/api/generateSvg.ts @@ -34,7 +34,7 @@ export function generateSvg(stats: Tstats): string { TOP - ${stats.rankPercentage}% + ${stats.wargameRankPercentage}% Dreamhack wargame stats ${stats.nickname} From 46bcf48dd05b2cb1aa26b9bb0f31fbe3a65d94b3 Mon Sep 17 00:00:00 2001 From: harry Date: Mon, 12 Aug 2024 11:08:52 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=EC=9C=BC=EB=A1=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=B0=9B=EC=95=84?= =?UTF-8?q?=EC=98=AC=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 사항: 1. /api/stats 라우터에 userId 파라미터가 아닌, username 파라미터를 전달하여 사용자 상태 정보를 받아올 수 있도록 변경 2. 사용자 상태를 받아오는 코드를 utils 함수로 분리 3. 현재 기능에서 불필요한 Types 제거 --- api/index.ts | 41 +++++++++++++++++++++++++---------------- api/types.ts | 25 +++++++++++++++++-------- api/utils.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/api/index.ts b/api/index.ts index d543921..426f9ea 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,8 +1,7 @@ import express, { Request, Response } from 'express'; -import { TuserData, Tstats } from './types'; -import fetch from 'node-fetch'; +import { Tstats } from './types'; import { generateSvg } from './generateSvg.js'; -import { getLastRank, calculateTopPercentage } from './utils.js'; +import { getLastRank, calculateTopPercentage, getUserId, getUserData } from './utils.js'; const app = express(); @@ -14,35 +13,45 @@ app.get('/api/stats', async (req: Request, res: Response) => { } try { - const [userResponse, lastRank] = await Promise.all([ - fetch(`https://dreamhack.io/api/v1/user/profile/${username}/`), - getLastRank(), - ]); + const userId = await getUserId(username); + const lastRank = await getLastRank(); - const userData = await userResponse.json() as TuserData; + if (!userId) { + return res.status(400).json({ error: 'User not found' }); + } + if (!lastRank) { + return res.status(500).json({ error: 'Failed to fetch last rank' }); + } - if (!userResponse.ok) { + const userData = await getUserData(userId); + if (!userData) { return res.status(400).json({ error: 'User information cannot be read.' }); } console.log('userData', userData); - const { nickname, contributions, exp, wargame, profile_image } = userData; + const { + nickname, + wargame, + // contributions, + // exp, + // profile_image + } = userData; - const overallTopPercentage = calculateTopPercentage(contributions.rank, lastRank); + // const overallTopPercentage = calculateTopPercentage(contributions.rank, lastRank); const wargameTopPercentage = calculateTopPercentage(wargame.rank, lastRank); const stats: Tstats = { nickname, - level: contributions.level, - exp, - rank: `${contributions.rank}/${lastRank || 'N/A'}`, - rankPercentage: overallTopPercentage, wargame_solved: userData.total_wargame, wargame_rank: `${wargame.rank}/${lastRank || 'N/A'}`, wargameRankPercentage: wargameTopPercentage, wargame_score: wargame.score, - profile_image, + // level: contributions.level, + // exp, + // rank: `${contributions.rank}/${lastRank || 'N/A'}`, + // rankPercentage: overallTopPercentage, + // profile_image, }; const svg = generateSvg(stats); diff --git a/api/types.ts b/api/types.ts index eee068f..220e5cb 100644 --- a/api/types.ts +++ b/api/types.ts @@ -21,20 +21,29 @@ export interface TuserData { export interface Tstats { nickname: string; - level: number; - exp: number; - rank: string; - rankPercentage: string; wargame_solved: number; wargame_rank: string; wargameRankPercentage: string; wargame_score: number; - ctf_rank?: number; - ctf_tier?: string; - ctf_rating?: number; - profile_image: string; + // level: number; + // exp: number; + // rank: string; + // rankPercentage: string; + // ctf_rank?: number; + // ctf_tier?: string; + // ctf_rating?: number; + // profile_image?: string; } export interface TgetLastRankResponse { count: number; + } + + export interface TUserRankingResponse { + results: TUserRankingResult[]; + } + + export interface TUserRankingResult { + id: number; + nickname: string; } \ No newline at end of file diff --git a/api/utils.ts b/api/utils.ts index dfbfd93..fe24f8e 100644 --- a/api/utils.ts +++ b/api/utils.ts @@ -1,5 +1,5 @@ import fetch from 'node-fetch'; -import { TgetLastRankResponse } from './types'; +import { TgetLastRankResponse, TUserRankingResponse, TuserData } from './types'; export async function getLastRank(): Promise { try { @@ -14,6 +14,33 @@ export async function getLastRank(): Promise { } } +export async function getUserId(username: string): Promise { + try { + const response = await fetch(`https://dreamhack.io/api/v1/ranking/wargame/?filter=global&limit=100&offset=0&search=${username}&scope=all&name=&category=`); + const data = await response.json() as TUserRankingResponse; + + const user = data.results.find(user => user.nickname === username); + return user ? user.id : null; + } catch (error) { + console.error('Error fetching user ID:', error); + return null; + } +} + +export async function getUserData(userId: number): Promise { + try { + const response = await fetch(`https://dreamhack.io/api/v1/user/profile/${userId}/`); + if (!response.ok) { + throw new Error('Failed to fetch user data'); + } + const userData = await response.json() as TuserData; + return userData; + } catch (error) { + console.error('Error fetching user data:', error); + return null; + } +} + export function calculateTopPercentage(rank: number | null, totalRanks: number | null): string { if (!rank || !totalRanks) return 'N/A'; const percentage = (rank / totalRanks) * 100; From f97076b1e49d41523f55dc44e429157c51862731 Mon Sep 17 00:00:00 2001 From: harry Date: Tue, 13 Aug 2024 22:25:47 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20Vercel=20cold=20start=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=B4=20=EA=B0=84=ED=97=90=EC=A0=81=20svg=EA=B0=80?= =?UTF-8?q?=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/index.ts b/api/index.ts index 426f9ea..59b57cb 100644 --- a/api/index.ts +++ b/api/index.ts @@ -13,8 +13,10 @@ app.get('/api/stats', async (req: Request, res: Response) => { } try { - const userId = await getUserId(username); - const lastRank = await getLastRank(); + const [userId, lastRank] = await Promise.all([ + getUserId(username), + getLastRank() + ]); if (!userId) { return res.status(400).json({ error: 'User not found' }); From 7ed627e9b9d5305f3e3a3ede06b526742794cca2 Mon Sep 17 00:00:00 2001 From: harry Date: Thu, 10 Oct 2024 09:58:54 +0900 Subject: [PATCH 05/12] =?UTF-8?q?chore:=20Vercel=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=9B=9C=EC=97=85=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 코드 변경은 `api/index.ts` 파일에 새로운 라우트 `/api/warmup`을 추가합니다. 이 라우트는 Vercel 서버의 웜업을 위해 사용됩니다. 마지막 랭크를 가져와서 성공 메시지와 함께 마지막 랭크를 반환합니다. 웜업 과정 중 오류가 발생하면 오류를 로그로 기록하고 실패 메시지를 반환합니다. --- api/index.ts | 16 ++++++++++++++++ vercel.json | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/api/index.ts b/api/index.ts index 59b57cb..2618651 100644 --- a/api/index.ts +++ b/api/index.ts @@ -71,4 +71,20 @@ app.get('/', (_req: Request, res: Response) => { return res.send('Server is running'); }); +// vercel 서버 웜업을 위한 라우트 +app.get('/api/warmup', async (_req: Request, res: Response) => { + try { + const lastRank = await getLastRank(); + + if (!lastRank) { + return res.status(500).json({ error: 'Failed to fetch last rank' }); + } + + return res.status(200).json({ message: 'Warmup successful', lastRank }); + } catch (error) { + console.error('Warmup error:', error); + return res.status(500).json({ error: 'Warmup failed' }); + } +}); + export default app; \ No newline at end of file diff --git a/vercel.json b/vercel.json index dd79837..a9e13bf 100644 --- a/vercel.json +++ b/vercel.json @@ -11,5 +11,11 @@ "src": "/(.*)", "dest": "/api/index.ts" } + ], + "crons": [ + { + "path": "/api/warmup", + "schedule": "*/10 * * * *" + } ] } \ No newline at end of file From 7f87d24d9d7393fc55129f536f27c67d43b35124 Mon Sep 17 00:00:00 2001 From: harry Date: Thu, 10 Oct 2024 16:15:37 +0900 Subject: [PATCH 06/12] =?UTF-8?q?chore:=20vercel=20hobby=20=ED=94=8C?= =?UTF-8?q?=EB=9E=9C=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20cron=20jobs?= =?UTF-8?q?=EB=A5=BC=2010=EB=B6=84=EB=A7=88=EB=8B=A4=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=97=86=EC=96=B4=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vercel.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/vercel.json b/vercel.json index a9e13bf..dd79837 100644 --- a/vercel.json +++ b/vercel.json @@ -11,11 +11,5 @@ "src": "/(.*)", "dest": "/api/index.ts" } - ], - "crons": [ - { - "path": "/api/warmup", - "schedule": "*/10 * * * *" - } ] } \ No newline at end of file From babe515a120b0f1f587aa84a284869b4f21ecf18 Mon Sep 17 00:00:00 2001 From: harry Date: Mon, 19 Jan 2026 14:36:31 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EB=8B=A4=ED=81=AC=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/api/most-solved.ts | 13 +++++--- src/pages/api/stats.ts | 9 ++++-- src/types/index.ts | 12 ++++++++ src/utils/generateCategorySvg.ts | 52 +++++++++++++++++++++++--------- src/utils/generateStatsSvg.ts | 49 ++++++++++++++++++++++-------- 5 files changed, 99 insertions(+), 36 deletions(-) diff --git a/src/pages/api/most-solved.ts b/src/pages/api/most-solved.ts index 426163b..77b4f17 100644 --- a/src/pages/api/most-solved.ts +++ b/src/pages/api/most-solved.ts @@ -1,5 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { TCategoryData, TCategoryStats } from '../../types'; +import { TCategoryData, TCategoryStats, Theme } from '../../types'; import { generateCategorySvg } from '../../utils/generateCategorySvg'; import { getUserId, getUserData } from '../../utils/dreamhack'; @@ -31,12 +31,15 @@ const defaultColors = [ export default async function handler(req: NextApiRequest, res: NextApiResponse) { console.time('⏱️ 전체 API 실행 시간'); - const { username } = req.query; + const { username, theme: themeParam } = req.query; if (!username || typeof username !== 'string') { return res.status(400).json({ error: 'Username is required' }); } + // 테마 파라미터 검증 + const theme: Theme = themeParam === 'dark' ? 'dark' : 'light'; + try { // 개별 API 호출 시간 측정 const userId = await measureTime('getUserId', () => getUserId(username as string)); @@ -69,8 +72,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) total_score: wargame.score || 0, categories: [] }; - - const svg = generateCategorySvg(emptyStats); + + const svg = generateCategorySvg(emptyStats, theme); res.setHeader('Content-Type', 'image/svg+xml'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); console.timeEnd('⏱️ 전체 API 실행 시간'); @@ -99,7 +102,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) categories }; - const svg = generateCategorySvg(categoryStats); + const svg = generateCategorySvg(categoryStats, theme); console.timeEnd('⏱️ 데이터 가공 및 SVG 생성'); res.setHeader('Content-Type', 'image/svg+xml'); diff --git a/src/pages/api/stats.ts b/src/pages/api/stats.ts index c6c32d3..fe81df1 100644 --- a/src/pages/api/stats.ts +++ b/src/pages/api/stats.ts @@ -1,5 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { Tstats } from '../../types'; +import { Tstats, Theme } from '../../types'; import { generateStatsSvg } from '../../utils/generateStatsSvg'; import { getLastRank, @@ -20,12 +20,15 @@ const measureTime = async (name: string, fn: () => Promise): Promise => export default async function handler(req: NextApiRequest, res: NextApiResponse) { console.time('⏱️ 전체 API 실행 시간'); - const { username } = req.query; + const { username, theme: themeParam } = req.query; if (!username || typeof username !== 'string') { return res.status(400).json({ error: 'Username is required' }); } + // 테마 파라미터 검증 + const theme: Theme = themeParam === 'dark' ? 'dark' : 'light'; + try { // 개별 API 호출 시간 측정 const userId = await measureTime('getUserId', () => getUserId(username as string)); @@ -64,7 +67,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) wargame_score: wargame.score, }; - const svg = generateStatsSvg(stats); + const svg = generateStatsSvg(stats, theme); console.timeEnd('⏱️ 데이터 가공 및 SVG 생성'); res.setHeader('Content-Type', 'image/svg+xml'); diff --git a/src/types/index.ts b/src/types/index.ts index 8b97c73..dab1400 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -73,4 +73,16 @@ export interface TCategoryStats { nickname: string; total_score: number; categories: TCategoryData[]; +} + +export type Theme = 'light' | 'dark'; + +export interface ThemeColors { + background: string; + cardBackground: string; + border: string; + title: string; + text: string; + subText: string; + accent: string; } \ No newline at end of file diff --git a/src/utils/generateCategorySvg.ts b/src/utils/generateCategorySvg.ts index 3bf34d3..52be5f1 100644 --- a/src/utils/generateCategorySvg.ts +++ b/src/utils/generateCategorySvg.ts @@ -1,6 +1,28 @@ -import { TCategoryStats } from '../types'; - -export function generateCategorySvg(stats: TCategoryStats): string { +import { TCategoryStats, Theme, ThemeColors } from '../types'; + +const themes: Record = { + light: { + background: '#ffffff', + cardBackground: '#f8fafc', + border: '#e2e8f0', + title: '#64748b', + text: '#0f172a', + subText: '#94a3b8', + accent: '#3b82f6', + }, + dark: { + background: '#0d1117', + cardBackground: '#21262d', + border: '#30363d', + title: '#8b949e', + text: '#e6edf3', + subText: '#7d8590', + accent: '#58a6ff', + }, +}; + +export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light'): string { + const colors = themes[theme]; // SVG 크기 및 차트 설정 const width = 390; const height = 190; @@ -27,7 +49,7 @@ export function generateCategorySvg(stats: TCategoryStats): string { // 카테고리가 없는 경우 if (categories.length === 0 || totalCategoryScore === 0) { - pieChart = ` + pieChart = ` No data`; legends = `No category data`; } else { @@ -50,7 +72,7 @@ export function generateCategorySvg(stats: TCategoryStats): string { `; @@ -82,7 +104,7 @@ export function generateCategorySvg(stats: TCategoryStats): string { // 중앙 총점 표시 (원형 차트 내부에) const centerCircle = ` - + Total ${totalScore} `; @@ -92,23 +114,23 @@ export function generateCategorySvg(stats: TCategoryStats): string { - + Most Solved Categories - + @@ -122,7 +144,7 @@ export function generateCategorySvg(stats: TCategoryStats): string { - + `; } \ No newline at end of file diff --git a/src/utils/generateStatsSvg.ts b/src/utils/generateStatsSvg.ts index 0c539fb..1b6c85f 100644 --- a/src/utils/generateStatsSvg.ts +++ b/src/utils/generateStatsSvg.ts @@ -1,20 +1,43 @@ -import { Tstats } from '../types'; +import { Tstats, Theme, ThemeColors } from '../types'; + +const themes: Record = { + light: { + background: '#ffffff', + cardBackground: '#f8fafc', + border: '#e2e8f0', + title: '#64748b', + text: '#0f172a', + subText: '#94a3b8', + accent: '#3b82f6', + }, + dark: { + background: '#0d1117', + cardBackground: '#21262d', + border: '#30363d', + title: '#8b949e', + text: '#e6edf3', + subText: '#7d8590', + accent: '#58a6ff', + }, +}; + +export function generateStatsSvg(stats: Tstats, theme: Theme = 'light'): string { + const colors = themes[theme]; -export function generateStatsSvg(stats: Tstats): string { return ` - + Dreamhack Wargame Stats @@ -30,25 +53,25 @@ export function generateStatsSvg(stats: Tstats): string { - + Solved Challenges ${stats.wargame_solved} - + Rank ${stats.wargame_rank} - + Score ${stats.wargame_score} - + `; } From 57ac4737e6c5ae92a0d381746a87e2d83b7adc8e Mon Sep 17 00:00:00 2001 From: harry Date: Mon, 19 Jan 2026 14:50:12 +0900 Subject: [PATCH 08/12] =?UTF-8?q?test:=20=EB=8B=A4=ED=81=AC=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/pages/api/stats.test.ts | 45 +++++++++++++++++++ .../utils/generateCategorySvg.test.ts | 34 ++++++++++++++ src/__tests__/utils/generateStatsSvg.test.ts | 44 ++++++++++++------ 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/__tests__/pages/api/stats.test.ts b/src/__tests__/pages/api/stats.test.ts index 2cf53cd..1e28941 100644 --- a/src/__tests__/pages/api/stats.test.ts +++ b/src/__tests__/pages/api/stats.test.ts @@ -75,7 +75,52 @@ describe('stats API 엔드포인트 테스트', () => { wargame_rank: `${mockUserData.wargame.rank}/${mockLastRank}`, wargameRankPercentage: '20.00', wargame_score: mockUserData.wargame.score, + }, 'light'); + }); + + it('dark 테마 파라미터로 SVG를 생성해야 함', async () => { + // 모킹된 데이터 설정 + const mockUserId = 20691; + const mockLastRank = 1000; + const mockUserData: TuserData = { + nickname: 'weakness', + contributions: { level: 1, rank: 100 }, + exp: 1000, + total_wargame: 50, + wargame: { solved: 50, rank: 200, score: 5000 }, + ctf: { rank: 300, tier: 'Gold', rating: 2000 }, + profile_image: 'image.jpg' + }; + const mockSvg = 'Mock Dark SVG'; + + // 모킹된 함수 구현 + (dreamhackUtils.getUserId as jest.Mock).mockResolvedValueOnce(mockUserId); + (dreamhackUtils.getLastRank as jest.Mock).mockResolvedValueOnce(mockLastRank); + (dreamhackUtils.getUserData as jest.Mock).mockResolvedValueOnce(mockUserData); + (dreamhackUtils.calculateTopPercentage as jest.Mock).mockReturnValueOnce('20.00'); + (generateSvgUtils.generateStatsSvg as jest.Mock).mockReturnValueOnce(mockSvg); + + // HTTP 요청 모킹 (theme=dark 포함) + const { req, res } = createMocks({ + method: 'GET', + query: { + username: 'weakness', + theme: 'dark', + }, }); + + // API 핸들러 호출 + await handler(req, res); + + // 응답 검증 + expect(res._getStatusCode()).toBe(200); + expect(res._getData()).toBe(mockSvg); + + // dark 테마로 호출되었는지 확인 + expect(generateSvgUtils.generateStatsSvg).toHaveBeenCalledWith( + expect.objectContaining({ nickname: 'weakness' }), + 'dark' + ); }); it('사용자 이름이 없을 때 400 에러를 반환해야 함', async () => { diff --git a/src/__tests__/utils/generateCategorySvg.test.ts b/src/__tests__/utils/generateCategorySvg.test.ts index 81efe9d..be828c6 100644 --- a/src/__tests__/utils/generateCategorySvg.test.ts +++ b/src/__tests__/utils/generateCategorySvg.test.ts @@ -112,4 +112,38 @@ describe('generateCategorySvg 유틸리티 함수 테스트', () => { expect(percentLabels.length).toBe(1); expect(percentLabels[0]).toContain('93%'); }); + + it('light 테마(기본)로 SVG를 생성해야 함', () => { + const mockStats: TCategoryStats = { + nickname: 'weakness', + total_score: 5000, + categories: [ + { name: 'web', score: 2000, rank: 50, color: '#ff6b6b' } + ] + }; + + const result = generateCategorySvg(mockStats); + + // light 테마 색상 확인 + expect(result).toContain('fill="#ffffff"'); // background + expect(result).toContain('fill="#f8fafc"'); // cardBackground + expect(result).toContain('stroke="#e2e8f0"'); // border + }); + + it('dark 테마로 SVG를 생성해야 함', () => { + const mockStats: TCategoryStats = { + nickname: 'weakness', + total_score: 5000, + categories: [ + { name: 'web', score: 2000, rank: 50, color: '#ff6b6b' } + ] + }; + + const result = generateCategorySvg(mockStats, 'dark'); + + // dark 테마 색상 확인 + expect(result).toContain('fill="#0d1117"'); // background + expect(result).toContain('fill="#21262d"'); // cardBackground + expect(result).toContain('stroke="#30363d"'); // border + }); }); \ No newline at end of file diff --git a/src/__tests__/utils/generateStatsSvg.test.ts b/src/__tests__/utils/generateStatsSvg.test.ts index 06d42a9..8ce86c2 100644 --- a/src/__tests__/utils/generateStatsSvg.test.ts +++ b/src/__tests__/utils/generateStatsSvg.test.ts @@ -2,15 +2,15 @@ import { generateStatsSvg } from '../../utils/generateStatsSvg'; import { Tstats } from '../../types'; describe('generateStatsSvg 유틸리티 함수 테스트', () => { - it('유효한 통계 데이터로 SVG를 생성해야 함', () => { - const mockStats: Tstats = { - nickname: 'testuser', - wargame_solved: 50, - wargame_rank: '200/1000', - wargameRankPercentage: '20.00', - wargame_score: 5000 - }; + const mockStats: Tstats = { + nickname: 'testuser', + wargame_solved: 50, + wargame_rank: '200/1000', + wargameRankPercentage: '20.00', + wargame_score: 5000 + }; + it('유효한 통계 데이터로 SVG를 생성해야 함', () => { const result = generateStatsSvg(mockStats); // SVG 문자열이 반환되었는지 확인 @@ -26,7 +26,7 @@ describe('generateStatsSvg 유틸리티 함수 테스트', () => { }); it('특수 문자가 포함된 사용자 이름을 올바르게 처리해야 함', () => { - const mockStats: Tstats = { + const specialStats: Tstats = { nickname: 'test', wargame_solved: 50, wargame_rank: '200/1000', @@ -34,14 +34,14 @@ describe('generateStatsSvg 유틸리티 함수 테스트', () => { wargame_score: 5000 }; - const result = generateStatsSvg(mockStats); + const result = generateStatsSvg(specialStats); // 특수 문자가 포함된 사용자 이름이 SVG에 포함되어 있는지 확인 expect(result).toContain('test'); }); it('긴 사용자 이름을 처리할 수 있어야 함', () => { - const mockStats: Tstats = { + const longNameStats: Tstats = { nickname: 'verylongusernamethatmightcauseissueswithsvgrendering', wargame_solved: 50, wargame_rank: '200/1000', @@ -49,9 +49,27 @@ describe('generateStatsSvg 유틸리티 함수 테스트', () => { wargame_score: 5000 }; - const result = generateStatsSvg(mockStats); - + const result = generateStatsSvg(longNameStats); + // 긴 사용자 이름이 SVG에 포함되어 있는지 확인 expect(result).toContain('verylongusernamethatmightcauseissueswithsvgrendering'); }); + + it('light 테마(기본)로 SVG를 생성해야 함', () => { + const result = generateStatsSvg(mockStats); + + // light 테마 색상 확인 + expect(result).toContain('fill="#ffffff"'); // background + expect(result).toContain('fill="#f8fafc"'); // cardBackground + expect(result).toContain('stroke="#e2e8f0"'); // border + }); + + it('dark 테마로 SVG를 생성해야 함', () => { + const result = generateStatsSvg(mockStats, 'dark'); + + // dark 테마 색상 확인 + expect(result).toContain('fill="#0d1117"'); // background + expect(result).toContain('fill="#21262d"'); // cardBackground + expect(result).toContain('stroke="#30363d"'); // border + }); }); \ No newline at end of file From 1a0521fbd98647c2023fe2a4a1bb0b7040867671 Mon Sep 17 00:00:00 2001 From: harry Date: Mon, 19 Jan 2026 14:53:39 +0900 Subject: [PATCH 09/12] =?UTF-8?q?docs:=20=ED=85=8C=EB=A7=88=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=ED=91=9C=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index b4e2a7a..a7f95e9 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,28 @@ README에 아래 코드를 추가하고 `사용자명`을 본인의 Dreamhack --- +## Themes + +`theme` 파라미터로 테마를 변경할 수 있습니다. + +```markdown +![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명&theme=dark) +``` + +| Theme | Wargame Stats | Most Solved Categories | +|-------|---------------|------------------------| +| `light` | ![Stats Light](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=light) | ![Categories Light](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness&theme=light) | +| `dark` | ![Stats Dark](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=dark) | ![Categories Dark](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness&theme=dark) | + +--- + ## Features | Feature | Description | |---------|-------------| | **Wargame Stats** | 해결한 문제 수, 랭킹, 점수, TOP % 표시 | | **Category Chart** | 카테고리별 점수 분포를 파이 차트로 시각화 | +| **Themes** | Light/Dark 테마 지원 | | **Auto Update** | 실시간으로 최신 통계 반영 | | **Caching** | Redis 캐싱으로 빠른 응답 속도 | From 4178e04bfdc511abfbea85b7b1f36ddd2b3a9c87 Mon Sep 17 00:00:00 2001 From: harry Date: Tue, 20 Jan 2026 18:56:39 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20UI=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/index.tsx | 261 +++++++++++++++++++++++-------------- src/styles/Home.module.css | 165 +++++++++++++++++++++++ 2 files changed, 326 insertions(+), 100 deletions(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index b765614..8f9b275 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,8 +1,43 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import Head from 'next/head'; import styles from '../styles/Home.module.css'; +type Theme = 'light' | 'dark'; +type CardType = 'stats' | 'most-solved'; + export default function Home() { + const [username, setUsername] = useState(''); + const [selectedTheme, setSelectedTheme] = useState('light'); + const [selectedCard, setSelectedCard] = useState('stats'); + const [previewKey, setPreviewKey] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + const baseUrl = 'https://dreamhack-readme-stats.vercel.app'; + + const getApiUrl = (card: CardType, theme: Theme, user: string) => { + const endpoint = card === 'stats' ? 'stats' : 'most-solved'; + return `${baseUrl}/api/${endpoint}?username=${user || '사용자명'}&theme=${theme}`; + }; + + const getPreviewUrl = (card: CardType, theme: Theme, user: string) => { + if (!user) return ''; + const endpoint = card === 'stats' ? 'stats' : 'most-solved'; + return `/api/${endpoint}?username=${user}&theme=${theme}`; + }; + + const getMarkdownCode = () => { + const altText = selectedCard === 'stats' ? 'Dreamhack Stats' : 'Dreamhack Category Chart'; + return `![${altText}](${getApiUrl(selectedCard, selectedTheme, username)})`; + }; + + const getHtmlCode = () => { + const altText = selectedCard === 'stats' ? 'Dreamhack Stats' : 'Dreamhack Category Chart'; + const user = username || '사용자명'; + return ` + ${altText} +`; + }; + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text).then(() => { alert('코드가 클립보드에 복사되었습니다!'); @@ -11,147 +46,173 @@ export default function Home() { }); }; + const handlePreview = () => { + if (!username.trim()) { + alert('사용자명을 입력해주세요.'); + return; + } + setIsLoading(true); + setPreviewKey(prev => prev + 1); + }; + + const handleImageLoad = () => { + setIsLoading(false); + }; + + const handleImageError = () => { + setIsLoading(false); + }; + return ( <> Dreamhack Readme Stats - GitHub 프로필에 Dreamhack 통계 표시하기 - - + - - - - - - - - - - - - - - +

Dreamhack Readme Stats

- 사용자 이름을 입력하여 Dreamhack 워게임 통계를 확인하세요. + GitHub README에 Dreamhack 워게임 통계를 표시하세요

-
-

사용 방법:

-
-
-

Markdown:

-
-
-

예시:

- - Dreamhack stats example - + {/* 미리보기 */} +
+

미리보기

+
+ {username && previewKey > 0 ? ( + Preview + ) : ( +
+ 사용자명을 입력하고 미리보기를 클릭하세요 +
+ )} +
-
-

카테고리 차트:

+ {/* 코드 생성 */} +
+

생성된 코드

+
-

Markdown:

-
- - ![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명) - + {getMarkdownCode()}
+
-

HTML:

-
- - {` - Dreamhack Category Chart -`} - + {getHtmlCode()}
-
-

카테고리 차트 예시:

- - Dreamhack category chart example - + {/* 테마 비교 */} +
+

테마 비교

+
+
+ Light + Light theme example +
+
+ Dark + Dark theme example +
+

- +
); -} \ No newline at end of file +} diff --git a/src/styles/Home.module.css b/src/styles/Home.module.css index 3c0ea26..770655d 100644 --- a/src/styles/Home.module.css +++ b/src/styles/Home.module.css @@ -205,4 +205,169 @@ .themeGrid { grid-template-columns: 1fr; } +} + +/* Config Panel */ +.configPanel { + max-width: 900px; + width: 100%; + padding: 1.5rem; + background: #fafafa; + border: 1px solid #eaeaea; + border-radius: 10px; + margin: 1rem 0; +} + +.configRow { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; + min-width: 150px; +} + +.inputGroup label { + font-size: 0.9rem; + font-weight: 500; + color: #333; +} + +.textInput { + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s ease; +} + +.textInput:focus { + outline: none; + border-color: #6e45e2; +} + +.selectInput { + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 1rem; + background: white; + cursor: pointer; + transition: border-color 0.2s ease; +} + +.selectInput:focus { + outline: none; + border-color: #6e45e2; +} + +.previewButton { + padding: 0.75rem 1.5rem; + background: #6e45e2; + color: white; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; + white-space: nowrap; +} + +.previewButton:hover { + background: #5a37c0; +} + +.previewButton:disabled { + background: #999; + cursor: not-allowed; +} + +/* Preview Section */ +.previewSection { + max-width: 900px; + width: 100%; + margin: 1.5rem 0; +} + +.previewSection h2 { + margin: 0 0 1rem 0; + font-size: 1.3rem; +} + +.previewContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + padding: 2rem; + background: #f5f5f5; + border: 1px solid #eaeaea; + border-radius: 10px; +} + +.previewImage { + max-width: 100%; + height: auto; +} + +.previewPlaceholder { + color: #999; + font-size: 1rem; +} + +/* Code Panel */ +.codePanel { + max-width: 900px; + width: 100%; + padding: 1.5rem; + background: #fafafa; + border: 1px solid #eaeaea; + border-radius: 10px; + margin: 1rem 0; +} + +.codePanel h2 { + margin: 0 0 1rem 0; + font-size: 1.3rem; +} + +/* Theme Comparison */ +.themeComparison { + max-width: 900px; + width: 100%; + margin: 2rem 0; +} + +.themeComparison h2 { + margin: 0 0 1rem 0; + font-size: 1.3rem; +} + +.themeLabel { + display: block; + font-weight: 500; + margin-bottom: 0.75rem; + color: #333; +} + +/* Responsive adjustments for config panel */ +@media (max-width: 600px) { + .configRow { + flex-direction: column; + } + + .inputGroup { + width: 100%; + } + + .previewButton { + width: 100%; + } } \ No newline at end of file From d5632bb084cc0b6c8972225e10a6da98e0ce08da Mon Sep 17 00:00:00 2001 From: harry Date: Tue, 20 Jan 2026 19:05:24 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20UI=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/index.tsx | 105 ++++++++++++++++++++++++++++++------- src/styles/Home.module.css | 91 ++++++++++++++++++++++++++++++-- 2 files changed, 173 insertions(+), 23 deletions(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 8f9b275..07f6def 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,10 +1,75 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import Head from 'next/head'; import styles from '../styles/Home.module.css'; type Theme = 'light' | 'dark'; type CardType = 'stats' | 'most-solved'; +interface DropdownOption { + value: T; + label: string; +} + +interface CustomDropdownProps { + options: DropdownOption[]; + value: T; + onChange: (value: T) => void; + placeholder?: string; +} + +function CustomDropdown({ options, value, onChange, placeholder }: CustomDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedOption = options.find(opt => opt.value === value); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + {isOpen && ( +
    + {options.map((option) => ( +
  • { + onChange(option.value); + setIsOpen(false); + }} + > + {option.label} + {option.value === value && ( + + + + )} +
  • + ))} +
+ )} +
+ ); +} + export default function Home() { const [username, setUsername] = useState(''); const [selectedTheme, setSelectedTheme] = useState('light'); @@ -12,6 +77,16 @@ export default function Home() { const [previewKey, setPreviewKey] = useState(0); const [isLoading, setIsLoading] = useState(false); + const cardOptions: DropdownOption[] = [ + { value: 'stats', label: 'Wargame Stats' }, + { value: 'most-solved', label: 'Most Solved Categories' }, + ]; + + const themeOptions: DropdownOption[] = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + ]; + const baseUrl = 'https://dreamhack-readme-stats.vercel.app'; const getApiUrl = (card: CardType, theme: Theme, user: string) => { @@ -95,29 +170,21 @@ export default function Home() {
- - + onChange={setSelectedCard} + />
- - + onChange={setSelectedTheme} + />