diff --git a/.gitignore b/.gitignore index 72ef033..815c45c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,6 @@ yarn-error.log* .vercel # typescript +.examples-build/ *.tsbuildinfo next-env.d.ts diff --git a/README.md b/README.md index 53111c6..0476ec1 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,23 @@ # Dreamhack Readme Stats -**GitHub README에 Dreamhack 워게임 통계를 표시하세요** +**GitHub README에 Dreamhack 워게임·CTF 통계 카드를 표시하세요** [![Used By](https://img.shields.io/endpoint?url=https://dreamhack-readme-stats.vercel.app/api/users-count)](https://github.com/search?q=https%3A%2F%2Fdreamhack-readme-stats.vercel.app%2Fapi%2F&type=code) ![GitHub release](https://img.shields.io/github/v/release/with-developer/dreamhack-readme-stats) ![License](https://img.shields.io/github/license/with-developer/dreamhack-readme-stats) -
+**[메인 페이지에서 내 카드 코드 만들기 →](https://dreamhack-readme-stats.vercel.app)** -![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness) +
-![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness) +![Dreamhack Stats](public/examples/stats.svg) +![Dreamhack Category Chart](public/examples/most-solved.svg) -![Dreamhack CTF](https://dreamhack-readme-stats.vercel.app/api/ctf?username=weakness) -![Dreamhack Recent Solves](https://dreamhack-readme-stats.vercel.app/api/solves?username=weakness) +![Dreamhack CTF](public/examples/ctf.svg) +![Dreamhack Recent Solves](public/examples/solves.svg) -![Dreamhack Level](https://dreamhack-readme-stats.vercel.app/api/level?username=weakness) +![Dreamhack Level](public/examples/level.svg) @@ -25,83 +26,127 @@ ## Quick Start -README에 아래 코드를 추가하고 `사용자명`을 본인의 Dreamhack 닉네임으로 변경하세요. -닉네임에 `&`, `#`, `+`, 공백 같은 특수문자가 있다면 [메인 페이지](https://dreamhack-readme-stats.vercel.app)에서 코드를 생성하세요. URL에 맞게 자동으로 인코딩됩니다. +[메인 페이지](https://dreamhack-readme-stats.vercel.app)에서 닉네임을 입력하고 카드와 테마를 고르면 README에 넣을 코드를 만들어 줍니다. 닉네임에 `&`, `#`, `+`, 공백 같은 특수문자가 있어도 주소에 맞게 인코딩됩니다. + +직접 넣으려면 아래 코드에서 `사용자명`을 본인의 Dreamhack 닉네임으로 바꾸세요. + +```markdown +![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명) +``` + +다른 카드는 [Cards](#cards), 색과 표시 방식은 [Options](#options)를 참고하세요. + +--- + +## Cards ### Wargame Stats +해결한 문제 수, 랭킹, 점수, 상위 %를 보여줍니다. + +![Dreamhack Stats](public/examples/stats.svg) + ```markdown ![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명) ``` ### Most Solved Categories +분야별 점수 비율을 원형 차트로 보여줍니다. `layout=bars`를 붙이면 분야별 점수·푼 문제 수·분야 순위를 막대로 보여줍니다. + +![Dreamhack Category Chart](public/examples/most-solved.svg) +![Dreamhack Category Bars](public/examples/most-solved-bars.svg) + ```markdown ![Dreamhack Categories](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명) +![Dreamhack Categories](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명&layout=bars) ``` -`&layout=bars`를 붙이면 분야별 점수·푼 문제 수·분야 순위를 막대로 보여줍니다. - ### CTF Season +Dreamhack CTF 티어와 레이팅은 시즌마다 새로 시작하므로, 카드는 시즌 하나의 결과(티어, 레이팅, 시즌 순위, 참가 대회 수)를 보여줍니다. 진행 중인 시즌은 `ONGOING`과 다음 티어까지 남은 점수, 끝난 시즌은 `FINAL`과 시즌 중 최고 레이팅을 표시합니다. + +`view=graph`는 그 시즌의 대회별 레이팅 그래프, `view=seasons`는 시즌마다 끝났을 때의 티어를 보여줍니다. 시즌은 `season`으로 고를 수 있습니다. + +![Dreamhack CTF](public/examples/ctf.svg) +![Dreamhack CTF Graph](public/examples/ctf-graph.svg) + +![Dreamhack CTF Seasons](public/examples/ctf-seasons.svg) + ```markdown ![Dreamhack CTF](https://dreamhack-readme-stats.vercel.app/api/ctf?username=사용자명) +![Dreamhack CTF](https://dreamhack-readme-stats.vercel.app/api/ctf?username=사용자명&view=graph) +![Dreamhack CTF Seasons](https://dreamhack-readme-stats.vercel.app/api/ctf?username=사용자명&view=seasons) ``` -Dreamhack CTF 티어와 레이팅은 시즌마다 새로 시작하므로, 카드는 시즌 하나의 결과를 보여줍니다. +### Recent Solves -- 옵션이 없으면 이번 시즌 결과가 있을 때는 이번 시즌, 없으면 결과가 있는 가장 최근 시즌을 보여줍니다 (Dreamhack 프로필과 같은 기준). -- `&season=7`처럼 시즌 번호를, `&season=current`로 이번 시즌을 고를 수 있습니다. -- `&view=graph`는 그 시즌의 대회별 레이팅 그래프, `&view=seasons`는 시즌마다 끝났을 때의 티어를 보여줍니다. +최근 푼 문제 5개를 난이도와 함께 보여줍니다. `sort=hardest`를 붙이면 가장 어려운 문제 5개와 각 문제를 푼 사람 수를 보여줍니다. -### Recent Solves +![Dreamhack Recent Solves](public/examples/solves.svg) +![Dreamhack Hardest Solves](public/examples/solves-hardest.svg) ```markdown ![Dreamhack Recent Solves](https://dreamhack-readme-stats.vercel.app/api/solves?username=사용자명) +![Dreamhack Hardest Solves](https://dreamhack-readme-stats.vercel.app/api/solves?username=사용자명&sort=hardest) ``` -최근 푼 문제 5개를 난이도와 함께 보여줍니다. `&sort=hardest`를 붙이면 가장 어려운 문제 5개와 각 문제를 푼 사람 수를 보여줍니다. - ### Level Badge +기여도 레벨, 다음 레벨까지 경험치, 기여도 순위, 워게임 상위 %, 대표 업적을 작은 배지로 보여줍니다. + +![Dreamhack Level](public/examples/level.svg) + ```markdown ![Dreamhack Level](https://dreamhack-readme-stats.vercel.app/api/level?username=사용자명) ``` -기여도 레벨, 다음 레벨까지 경험치, 기여도 순위, 워게임 상위 %, 대표 업적을 작은 배지로 보여줍니다. +--- + +## Options + +카드 주소 뒤에 `&옵션=값` 형식으로 붙입니다. 예: `.../api/ctf?username=사용자명&theme=dark&view=graph` -> 💡 클릭 시 Dreamhack 프로필로 이동하게 하려면 HTML 사용 (`사용자ID`는 프로필 주소 `https://dreamhack.io/users/12345`의 숫자이며, 메인 페이지에서 미리보기하면 자동으로 채워집니다): -> ```html -> -> -> -> ``` +| 옵션 | 카드 | 값 | 설명 | +|------|------|----|------| +| `theme` | 모든 카드 | `light`(기본), `dark`, `dracula`, `tokyonight`, `nord`, `gruvbox`, `transparent` | 카드 색 ([Themes](#themes)) | +| `layout` | Most Solved Categories | `bars` | 막대형 (기본은 원형 차트) | +| `season` | CTF Season | `7` 같은 시즌 번호, `current`(이번 시즌) | 보여줄 시즌. 없으면 이번 시즌 결과가 있을 때 이번 시즌, 없으면 결과가 있는 가장 최근 시즌 (Dreamhack 프로필과 같은 기준) | +| `view` | CTF Season | `graph`, `seasons` | 대회별 레이팅 그래프 / 시즌별 결과 | +| `sort` | Recent Solves | `hardest` | 가장 어려운 문제 순 (기본은 최근 순) | --- ## Themes -모든 카드에서 `theme` 파라미터로 테마를 변경할 수 있습니다. + + + + + + + + + + + + + + + + + +
light
light 테마
dark
dark 테마
dracula
dracula 테마
tokyonight
tokyonight 테마
nord
nord 테마
gruvbox
gruvbox 테마
transparent
transparent 테마
-```markdown -![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명&theme=dark) -``` +`transparent`는 배경과 테두리 없이 그려서 README 배경 어디에나 어울립니다. 글자는 라이트·다크 배경 모두에서 읽히도록 중간 회색입니다. -| 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) | -| `dracula` | ![Stats Dracula](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=dracula) | | -| `tokyonight` | ![Stats Tokyo Night](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=tokyonight) | | -| `nord` | ![Stats Nord](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=nord) | | -| `gruvbox` | ![Stats Gruvbox](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=gruvbox) | | -| `transparent` | ![Stats Transparent](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=transparent) | | +--- -`transparent`는 배경과 테두리 없이 그려서 README 배경 어디에나 어울립니다. 글자는 라이트·다크 배경 모두에서 읽히도록 중간 회색입니다. +## Tips -### GitHub 테마 따라가기 (Auto) +### GitHub 테마 따라가기 -README를 보는 사람의 GitHub 테마(라이트/다크)에 맞춰 카드를 바꾸려면 ``를 사용하세요. [메인 페이지](https://dreamhack-readme-stats.vercel.app)에서 테마를 `Auto`로 고르면 이 코드를 만들어 줍니다. +README를 보는 사람의 GitHub 테마(라이트/다크)에 맞춰 카드를 바꾸려면 ``를 사용하세요. 메인 페이지에서 테마를 `Auto`로 고르면 이 코드를 만들어 줍니다. ```html @@ -110,20 +155,31 @@ README를 보는 사람의 GitHub 테마(라이트/다크)에 맞춰 카드를 ``` +### 클릭하면 Dreamhack 프로필로 이동 + +카드를 ``로 감싸세요. `사용자ID`는 프로필 주소 `https://dreamhack.io/users/12345`의 숫자이며, 메인 페이지에서 미리보기하면 자동으로 채워집니다. + +```html + + + +``` + --- -## Features +## FAQ + +**"User not found"가 나와요.**\ +닉네임은 대소문자를 구분합니다. 워게임 랭킹에 오른 사용자만 찾을 수 있습니다. + +**닉네임에 특수문자가 있어요.**\ +`&`, `#`, `+`, 공백 같은 문자는 주소에 맞게 바꿔야 합니다. [메인 페이지](https://dreamhack-readme-stats.vercel.app)에서 코드를 만들면 자동으로 처리됩니다. -| Feature | Description | -|---------|-------------| -| **Wargame Stats** | 해결한 문제 수, 랭킹, 점수, TOP % 표시 | -| **Category Chart** | 카테고리별 점수 분포를 파이 차트 또는 막대(`layout=bars`)로 시각화 | -| **CTF Season** | 시즌별 CTF 티어·레이팅·시즌 순위, 대회별 레이팅 그래프, 시즌별 결과 | -| **Recent Solves** | 최근 푼 문제 또는 가장 어려운 문제 5개 | -| **Level Badge** | 기여도 레벨·경험치·순위와 대표 업적 | -| **Themes** | Light/Dark 외 Dracula, Tokyo Night, Nord, Gruvbox, Transparent 테마와 GitHub 테마 자동 전환 | -| **Auto Update** | 실시간으로 최신 통계 반영 | -| **Caching** | Redis 캐싱으로 빠른 응답 속도 | +**통계가 바로 바뀌지 않아요.**\ +GitHub이 README의 이미지를 몇 분 동안 캐시합니다. 잠시 뒤 새로고침하세요. + +**CTF 카드에 지난 시즌이 나와요.**\ +이번 시즌 대회에 참가하지 않았으면 결과가 있는 가장 최근 시즌을 보여줍니다. 이번 시즌을 고정하려면 `season=current`를 붙이세요. --- @@ -150,16 +206,21 @@ http://localhost:3000 에서 확인 | Variable | Required | Description | |----------|----------|-------------| -| `REDIS_URL` | No | Redis 연결 URL (캐싱용) | -| `GITHUB_TOKEN` | No | GitHub API 토큰 (사용자 수 집계용) | +| `REDIS_URL` | No | Redis 연결 URL (닉네임 → 사용자 ID 캐싱용) | +| `GITHUB_TOKEN` | No | GitHub API 토큰 (Used By 배지의 사용자 수 집계용) | ---- +### Example Images + +README와 메인 페이지의 예시 카드(`public/examples/`)는 목업 데이터로 만든 이미지입니다. 카드 디자인을 바꾼 뒤에는 다시 만드세요. + +```bash +npm run examples +``` -## Tech Stack +### Tech Stack -- **Framework**: Next.js 14 -- **Language**: TypeScript -- **Cache**: Redis (Upstash) +- **Framework**: Next.js 14 (TypeScript) +- **Cache**: Redis (Redis Cloud) - **Deploy**: Vercel --- diff --git a/package.json b/package.json index e6c0895..47a46a3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint": "next lint", "format": "prettier --write .", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "examples": "tsc -p scripts/examples && node .examples-build/scripts/examples/generate.js" }, "dependencies": { "ioredis": "^5.5.0", diff --git a/public/examples/ctf-graph.svg b/public/examples/ctf-graph.svg new file mode 100644 index 0000000..536f295 --- /dev/null +++ b/public/examples/ctf-graph.svg @@ -0,0 +1,52 @@ + + + + + + + Dreamhack CTF Season 8 + + ONGOING + example_user + Platinum I2034 + #37/1231 · 7 contests + + + + + GOLD + + PLATINUM + + + + + + + #3 + + 2034 + + + diff --git a/public/examples/ctf-seasons.svg b/public/examples/ctf-seasons.svg new file mode 100644 index 0000000..b0990cd --- /dev/null +++ b/public/examples/ctf-seasons.svg @@ -0,0 +1,66 @@ + + + + + + + Dreamhack CTF Seasons + * ongoing + example_user + Platinum I2034 + + + SILVER + + GOLD + + PLATINUM + + DIAMOND + + + + + + S2 + Season 4 + + + G3 + Season 5 + + + G1 + Season 6 + + + P3 + Season 7 + + + P1 + Season 8* + + + diff --git a/public/examples/ctf.svg b/public/examples/ctf.svg new file mode 100644 index 0000000..51ae6c9 --- /dev/null +++ b/public/examples/ctf.svg @@ -0,0 +1,51 @@ + + + + + + + Dreamhack CTF Season 8 + + ONGOING + example_user + TIER + Platinum I + + Rating 2034 + 66 to Diamond + + + + + + + Season Rank + 37/1231 + + + + TOP + 3.01% + + + + Contests + 7 + + + + diff --git a/public/examples/level.svg b/public/examples/level.svg new file mode 100644 index 0000000..da5eb42 --- /dev/null +++ b/public/examples/level.svg @@ -0,0 +1,32 @@ + + + + + + + Dreamhack + + Night Owl + + + Lv.23 + example_user + Contrib. #214Wargame TOP 0.38% + + + + + EXP 1320 / 1890 + 69% to Lv.24 + + + + diff --git a/public/examples/most-solved-bars.svg b/public/examples/most-solved-bars.svg new file mode 100644 index 0000000..82ad868 --- /dev/null +++ b/public/examples/most-solved-bars.svg @@ -0,0 +1,61 @@ + + + + + + + + Wargame Categories + Total 15230 + + + Score + Solved + Rank + + + + Web + + + 5200 + 160 + #142 + Pwnable + + + 4100 + 120 + #260 + Reversing + + + 3200 + 95 + #310 + Crypto + + + 1900 + 70 + #455 + Others + + + 830 + 67 + #390 + + + + diff --git a/public/examples/most-solved.svg b/public/examples/most-solved.svg new file mode 100644 index 0000000..82ab8e4 --- /dev/null +++ b/public/examples/most-solved.svg @@ -0,0 +1,99 @@ + + + + + + + + Most Solved Categories + + + + + + + + + Web + 34% + + + Pwnable + 27% + + + Reversing + 21% + + + Crypto + 12% + + + Others + 5% + + + + + + + + + + + + 34% + + 27% + + 21% + + 12% + + + + + + Total + 15230 + + + + + + diff --git a/public/examples/solves-hardest.svg b/public/examples/solves-hardest.svg new file mode 100644 index 0000000..1533d00 --- /dev/null +++ b/public/examples/solves-hardest.svg @@ -0,0 +1,60 @@ + + + + + + + + Hardest Solves + example_user + + + + + + M3 + Kernel Sandbox Escape + pwn + 9 solvers + + + + D2 + VM Obfuscator + rev + 23 solvers + + + + D4 + Lattice Lullaby + crypto + 41 solvers + + + + P1 + SSRF to RCE + web + 66 solvers + + + + P2 + heap-feng-shui + pwn + 87 solvers + + + + + diff --git a/public/examples/solves.svg b/public/examples/solves.svg new file mode 100644 index 0000000..5fccbdd --- /dev/null +++ b/public/examples/solves.svg @@ -0,0 +1,60 @@ + + + + + + + + Recent Solves + example_user + + + + + + P2 + heap-feng-shui + pwn + 2026-11-12 + + + + G1 + JWT Confusion + web + 2026-11-10 + + + + D4 + Lattice Lullaby + crypto + 2026-11-07 + + + + S1 + packed-rev + rev + 2026-11-05 + + + + S3 + cookie-jar + web + 2026-11-02 + + + + + diff --git a/public/examples/stats-dark.svg b/public/examples/stats-dark.svg new file mode 100644 index 0000000..51acdd5 --- /dev/null +++ b/public/examples/stats-dark.svg @@ -0,0 +1,49 @@ + + + + + + + + Dreamhack Wargame Stats + + + example_user + + + + TOP + 0.38% + + + + + + Solved Challenges + 512 + + + + + Rank + 128/33400 + + + + + Score + 15230 + + + + + diff --git a/public/examples/stats-dracula.svg b/public/examples/stats-dracula.svg new file mode 100644 index 0000000..a662117 --- /dev/null +++ b/public/examples/stats-dracula.svg @@ -0,0 +1,49 @@ + + + + + + + + Dreamhack Wargame Stats + + + example_user + + + + TOP + 0.38% + + + + + + Solved Challenges + 512 + + + + + Rank + 128/33400 + + + + + Score + 15230 + + + + + diff --git a/public/examples/stats-gruvbox.svg b/public/examples/stats-gruvbox.svg new file mode 100644 index 0000000..8828b18 --- /dev/null +++ b/public/examples/stats-gruvbox.svg @@ -0,0 +1,49 @@ + + + + + + + + Dreamhack Wargame Stats + + + example_user + + + + TOP + 0.38% + + + + + + Solved Challenges + 512 + + + + + Rank + 128/33400 + + + + + Score + 15230 + + + + + diff --git a/public/examples/stats-nord.svg b/public/examples/stats-nord.svg new file mode 100644 index 0000000..22ca250 --- /dev/null +++ b/public/examples/stats-nord.svg @@ -0,0 +1,49 @@ + + + + + + + + Dreamhack Wargame Stats + + + example_user + + + + TOP + 0.38% + + + + + + Solved Challenges + 512 + + + + + Rank + 128/33400 + + + + + Score + 15230 + + + + + diff --git a/public/examples/stats-tokyonight.svg b/public/examples/stats-tokyonight.svg new file mode 100644 index 0000000..8578b6e --- /dev/null +++ b/public/examples/stats-tokyonight.svg @@ -0,0 +1,49 @@ + + + + + + + + Dreamhack Wargame Stats + + + example_user + + + + TOP + 0.38% + + + + + + Solved Challenges + 512 + + + + + Rank + 128/33400 + + + + + Score + 15230 + + + + + diff --git a/public/examples/stats-transparent.svg b/public/examples/stats-transparent.svg new file mode 100644 index 0000000..ef70e11 --- /dev/null +++ b/public/examples/stats-transparent.svg @@ -0,0 +1,49 @@ + + + + + + + + Dreamhack Wargame Stats + + + example_user + + + + TOP + 0.38% + + + + + + Solved Challenges + 512 + + + + + Rank + 128/33400 + + + + + Score + 15230 + + + + + diff --git a/public/examples/stats.svg b/public/examples/stats.svg new file mode 100644 index 0000000..36fdd31 --- /dev/null +++ b/public/examples/stats.svg @@ -0,0 +1,49 @@ + + + + + + + + Dreamhack Wargame Stats + + + example_user + + + + TOP + 0.38% + + + + + + Solved Challenges + 512 + + + + + Rank + 128/33400 + + + + + Score + 15230 + + + + + diff --git a/scripts/examples/generate.ts b/scripts/examples/generate.ts new file mode 100644 index 0000000..35adddd --- /dev/null +++ b/scripts/examples/generate.ts @@ -0,0 +1,25 @@ +// README에 보여줄 예시 카드를 목업 데이터로 만들어 public/examples/에 저장 +// 실행: npm run examples (카드 디자인을 바꾼 뒤 다시 실행해 예시 이미지를 갱신) +import fs from 'fs'; +import path from 'path'; +import { CARD_TYPES } from '../../src/utils/cardCode'; +import { THEME_NAMES } from '../../src/utils/svg'; +import { renderExampleCard } from '../../src/utils/exampleCards'; + +const outDir = path.join(process.cwd(), 'public', 'examples'); +fs.mkdirSync(outDir, { recursive: true }); + +const files: Record = {}; +// 카드 종류별 light 예시 (파일 이름 = 카드 종류) +for (const card of CARD_TYPES) { + files[`${card}.svg`] = renderExampleCard(card, 'light'); +} +// 테마별 워게임 카드 (light는 stats.svg) +for (const theme of THEME_NAMES) { + if (theme !== 'light') files[`stats-${theme}.svg`] = renderExampleCard('stats', theme); +} + +for (const [name, svg] of Object.entries(files)) { + fs.writeFileSync(path.join(outDir, name), `${svg.trim()}\n`); +} +console.log(`${Object.keys(files).length} example cards written to ${path.relative(process.cwd(), outDir)}/`); diff --git a/scripts/examples/tsconfig.json b/scripts/examples/tsconfig.json new file mode 100644 index 0000000..9f1d064 --- /dev/null +++ b/scripts/examples/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "target": "es2019", + "lib": ["es2019"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "rootDir": "../..", + "outDir": "../../.examples-build" + }, + "files": ["generate.ts"] +} diff --git a/src/__tests__/pages/api/example.test.ts b/src/__tests__/pages/api/example.test.ts new file mode 100644 index 0000000..3e49f6a --- /dev/null +++ b/src/__tests__/pages/api/example.test.ts @@ -0,0 +1,45 @@ +import { createMocks } from 'node-mocks-http'; +import handler from '../../../pages/api/example'; +import { CARD_TYPES } from '../../../utils/cardCode'; +import { THEME_NAMES } from '../../../utils/svg'; +import { renderExampleCard, EXAMPLE_NICKNAME } from '../../../utils/exampleCards'; + +const call = (query: Record) => { + const { req, res } = createMocks({ method: 'GET', query }); + handler(req, res); + return res; +}; + +describe('example API 엔드포인트 테스트', () => { + it('고른 카드와 테마의 목업 카드를 하루 캐시로 돌려줘야 함', () => { + const res = call({ card: 'ctf-graph', theme: 'dracula' }); + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty('content-type', 'image/svg+xml'); + expect(res._getHeaders()).toHaveProperty('cache-control', 'public, max-age=86400, s-maxage=86400'); + expect(res._getData()).toBe(renderExampleCard('ctf-graph', 'dracula')); + }); + + it('모르는 카드·테마는 워게임 카드·light로 처리해야 함', () => { + for (const query of [{}, { card: 'unknown', theme: 'neon' }, { card: ['ctf'], theme: ['dark'] }, { card: '__proto__' }]) { + expect(call(query)._getData()).toBe(renderExampleCard('stats', 'light')); + } + }); +}); + +describe('renderExampleCard 함수 테스트', () => { + it('모든 카드·테마 조합이 예시 닉네임이 들어간 올바른 SVG여야 함', () => { + for (const card of CARD_TYPES) { + for (const theme of THEME_NAMES) { + const svg = renderExampleCard(card, theme); + const doc = new DOMParser().parseFromString(svg.trim(), 'image/svg+xml'); + expect(doc.getElementsByTagName('parsererror')).toHaveLength(0); + // 분야 카드는 닉네임을 표시하지 않음 + if (!card.startsWith('most-solved')) expect(svg).toContain(EXAMPLE_NICKNAME); + } + } + }); + + it('같은 입력이면 항상 같은 SVG여야 함 (README 예시 파일을 다시 만들어도 바뀌지 않도록)', () => { + expect(renderExampleCard('solves', 'nord')).toBe(renderExampleCard('solves', 'nord')); + }); +}); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f5bdeec..16cba2d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,6 +1,15 @@ import { AppProps } from 'next/app'; +import { Inter, JetBrains_Mono } from 'next/font/google'; import '../styles/globals.css'; +// 빌드할 때 내려받아 사이트에서 직접 제공 (방문할 때 Google Fonts를 부르지 않음) +const sans = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' }); +const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap' }); + export default function App({ Component, pageProps }: AppProps) { - return ; -} \ No newline at end of file + return ( +
+ +
+ ); +} diff --git a/src/pages/api/example.ts b/src/pages/api/example.ts new file mode 100644 index 0000000..05c2f93 --- /dev/null +++ b/src/pages/api/example.ts @@ -0,0 +1,18 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { CARD_TYPES, CardType } from '../../utils/cardCode'; +import { parseTheme } from '../../utils/svg'; +import { renderExampleCard } from '../../utils/exampleCards'; + +// 메인 페이지에서 닉네임을 넣기 전에 보여줄 예시 카드 (목업 데이터, Dreamhack 조회 없음) +// /api/example?card=ctf-graph&theme=dracula +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const { card: cardParam, theme: themeParam } = req.query; + const card: CardType = typeof cardParam === 'string' && (CARD_TYPES as string[]).includes(cardParam) + ? (cardParam as CardType) + : 'stats'; + + res.setHeader('Content-Type', 'image/svg+xml'); + // 같은 주소는 항상 같은 카드라 하루 동안 캐시 + res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=86400'); + return res.status(200).send(renderExampleCard(card, parseTheme(themeParam))); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ba828d6..d3e0771 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Head from 'next/head'; import styles from '../styles/Home.module.css'; import { encodeUsername } from '../utils/encodeUsername'; import { Theme } from '../types'; +import { themes } from '../utils/svg'; import { buildCardUrl, buildHtmlCode, @@ -15,89 +16,119 @@ import { ThemeChoice, } from '../utils/cardCode'; -interface DropdownOption { - value: T; - label: string; - // 선택 칸에 보일 짧은 이름 (펼친 목록에는 label 전체) - shortLabel?: string; +const BASE_URL = 'https://dreamhack-readme-stats.vercel.app'; +const GITHUB_URL = 'https://github.com/with-developer/dreamhack-readme-stats'; + +// 카드 고르기 칸에 보일 이름 +const CARD_NAMES: Record = { + stats: '워게임 통계', + 'most-solved': '분야 · 원형', + 'most-solved-bars': '분야 · 막대', + ctf: 'CTF 시즌', + 'ctf-graph': 'CTF 그래프', + 'ctf-seasons': 'CTF 시즌별', + solves: '최근 풀이', + 'solves-hardest': '어려운 풀이', + level: '레벨 배지', +}; + +const THEME_CHOICES = Object.keys(THEME_LABELS) as ThemeChoice[]; + +type ReadmeBackground = 'light' | 'dark'; +type CodeTab = 'markdown' | 'html'; +type PreviewState = 'loading' | 'ready' | 'error'; + +// 닉네임을 넣기 전에 보여줄 예시 카드 (light는 저장된 파일, 그 밖의 테마는 목업 카드 API) +function exampleSrc(card: CardType, theme: Theme): string { + return theme === 'light' ? `/examples/${card}.svg` : `/api/example?card=${card}&theme=${theme}`; } -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); - }, []); - +// 테마 칩의 색 견본: 카드 배경과 강조색 (transparent는 체크 무늬, auto는 반반) +function ThemeSwatch({ theme }: { theme: ThemeChoice }) { + if (theme === 'auto') { + return ( +