feat: 전체 UI 구현 및 백엔드 인증 API 연동
- 테마 시스템: CSS 변수 + data-theme + Tailwind v4 시맨틱 색상 (다크모드 지원) - 공통 컴포넌트: CodeBlock, Alert, StepGuide, CopyButton, TableOfContents - 가이드 콘텐츠 8개 섹션 (React.lazy 동적 로딩, 실제 인프라 검증 완료) - 관리자 페이지 4개 (사용자/롤/권한/통계) - 레이아웃: 반응형 사이드바 + 테마 토글 + ScrollSpy 목차 - 인증: Google OAuth 로그인/세션복원/로그아웃 백엔드 API 연동 - 개발모드 mock 인증 (import.meta.env.DEV 전용) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
4629046550
커밋
456cfdddd9
14
.claude/scripts/on-commit.sh
Executable file
14
.claude/scripts/on-commit.sh
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
23
.claude/scripts/on-post-compact.sh
Executable file
23
.claude/scripts/on-post-compact.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
|
fi
|
||||||
8
.claude/scripts/on-pre-compact.sh
Executable file
8
.claude/scripts/on-pre-compact.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
@ -43,5 +43,42 @@
|
|||||||
"Read(./**/.env.*)",
|
"Read(./**/.env.*)",
|
||||||
"Read(./**/secrets/**)"
|
"Read(./**/secrets/**)"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,22 +22,142 @@ $ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
|
|||||||
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
||||||
- 사용 중인 프레임워크, 라이브러리 감지
|
- 사용 중인 프레임워크, 라이브러리 감지
|
||||||
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
||||||
|
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
|
||||||
|
|
||||||
### 2. CLAUDE.md 생성
|
### 2. CLAUDE.md 생성
|
||||||
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
||||||
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
||||||
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
||||||
- 테스트 실행 명령어
|
- 테스트 실행 명령어
|
||||||
|
- lint 실행 명령어 (감지된 도구 기반)
|
||||||
- 프로젝트 디렉토리 구조 요약
|
- 프로젝트 디렉토리 구조 요약
|
||||||
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
||||||
|
|
||||||
### 3. .claude/ 디렉토리 구성
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우:
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정
|
```bash
|
||||||
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow)
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
|
```
|
||||||
|
|
||||||
### 4. Git Hooks 설정
|
### 3. .claude/ 디렉토리 구성
|
||||||
|
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
||||||
|
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조)
|
||||||
|
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
||||||
|
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
|
||||||
|
|
||||||
|
### 4. Hook 스크립트 생성
|
||||||
|
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
|
||||||
|
|
||||||
|
- `.claude/scripts/on-pre-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-post-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-commit.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Git Hooks 설정
|
||||||
```bash
|
```bash
|
||||||
git config core.hooksPath .githooks
|
git config core.hooksPath .githooks
|
||||||
```
|
```
|
||||||
@ -46,7 +166,7 @@ git config core.hooksPath .githooks
|
|||||||
chmod +x .githooks/*
|
chmod +x .githooks/*
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 프로젝트 타입별 추가 설정
|
### 6. 프로젝트 타입별 추가 설정
|
||||||
|
|
||||||
#### java-maven
|
#### java-maven
|
||||||
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
||||||
@ -63,7 +183,7 @@ chmod +x .githooks/*
|
|||||||
- `.npmrc` Nexus 레지스트리 설정 확인
|
- `.npmrc` Nexus 레지스트리 설정 확인
|
||||||
- `npm install && npm run build` 성공 확인
|
- `npm install && npm run build` 성공 확인
|
||||||
|
|
||||||
### 6. .gitignore 확인
|
### 7. .gitignore 확인
|
||||||
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
||||||
```
|
```
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
@ -73,18 +193,54 @@ chmod +x .githooks/*
|
|||||||
*.local
|
*.local
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. workflow-version.json 생성
|
### 8. Git exclude 설정
|
||||||
`.claude/workflow-version.json` 파일을 생성하여 현재 글로벌 워크플로우 버전 기록:
|
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
|
||||||
|
# Claude Code 워크플로우 (로컬 전용)
|
||||||
|
docs/CHANGELOG.md
|
||||||
|
*.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Memory 초기화
|
||||||
|
프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects/<project-hash>/memory/`) 다음 파일들을 생성:
|
||||||
|
|
||||||
|
- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내)
|
||||||
|
- 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크
|
||||||
|
- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트
|
||||||
|
- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작
|
||||||
|
- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약
|
||||||
|
- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록)
|
||||||
|
- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴)
|
||||||
|
|
||||||
|
### 10. Lint 도구 확인
|
||||||
|
- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안
|
||||||
|
- Java: checkstyle, spotless 등 설정 확인
|
||||||
|
- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인
|
||||||
|
|
||||||
|
### 11. workflow-version.json 생성
|
||||||
|
Gitea API로 최신 팀 워크플로우 버전을 조회:
|
||||||
|
```bash
|
||||||
|
curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
|
```
|
||||||
|
조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용.
|
||||||
|
|
||||||
|
`.claude/workflow-version.json` 파일 생성:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"applied_global_version": "1.0.0",
|
"applied_global_version": "<조회된 버전>",
|
||||||
"applied_date": "현재날짜",
|
"applied_date": "<현재날짜>",
|
||||||
"project_type": "감지된타입"
|
"project_type": "<감지된타입>",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. 검증 및 요약
|
### 12. 검증 및 요약
|
||||||
- 생성/수정된 파일 목록 출력
|
- 생성/수정된 파일 목록 출력
|
||||||
- `git config core.hooksPath` 확인
|
- `git config core.hooksPath` 확인
|
||||||
- 빌드 명령 실행 가능 확인
|
- 빌드 명령 실행 가능 확인
|
||||||
- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등)
|
- Hook 스크립트 실행 권한 확인
|
||||||
|
- 다음 단계 안내:
|
||||||
|
- 개발 시작, 첫 커밋 방법
|
||||||
|
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`
|
||||||
|
|||||||
@ -13,11 +13,11 @@ Gitea API로 template-common 리포의 workflow-version.json 조회:
|
|||||||
```bash
|
```bash
|
||||||
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
||||||
|
|
||||||
curl -sf "${GITEA_URL}/api/v1/repos/gc/template-common/raw/workflow-version.json"
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 버전 비교
|
### 2. 버전 비교
|
||||||
로컬 `.claude/workflow-version.json`과 비교:
|
로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교:
|
||||||
- 버전 일치 → "최신 버전입니다" 안내 후 종료
|
- 버전 일치 → "최신 버전입니다" 안내 후 종료
|
||||||
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
|
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
|
||||||
|
|
||||||
@ -26,8 +26,19 @@ curl -sf "${GITEA_URL}/api/v1/repos/gc/template-common/raw/workflow-version.json
|
|||||||
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
||||||
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
||||||
|
|
||||||
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
|
```
|
||||||
|
|
||||||
### 4. 파일 다운로드 및 적용
|
### 4. 파일 다운로드 및 적용
|
||||||
Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
|
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
|
||||||
|
|
||||||
#### 4-1. 규칙 파일 (덮어쓰기)
|
#### 4-1. 규칙 파일 (덮어쓰기)
|
||||||
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
||||||
@ -42,13 +53,17 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
|
|||||||
#### 4-2. settings.json (부분 갱신)
|
#### 4-2. settings.json (부분 갱신)
|
||||||
- `deny` 목록: 글로벌 최신으로 교체
|
- `deny` 목록: 글로벌 최신으로 교체
|
||||||
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
||||||
- `hooks`: 글로벌 최신으로 교체
|
- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가)
|
||||||
|
- SessionStart(compact) → on-post-compact.sh
|
||||||
|
- PreCompact → on-pre-compact.sh
|
||||||
|
- PostToolUse(Bash) → on-commit.sh
|
||||||
|
|
||||||
#### 4-3. 스킬 파일 (덮어쓰기)
|
#### 4-3. 스킬 파일 (덮어쓰기)
|
||||||
```
|
```
|
||||||
.claude/skills/create-mr/SKILL.md
|
.claude/skills/create-mr/SKILL.md
|
||||||
.claude/skills/fix-issue/SKILL.md
|
.claude/skills/fix-issue/SKILL.md
|
||||||
.claude/skills/sync-team-workflow/SKILL.md
|
.claude/skills/sync-team-workflow/SKILL.md
|
||||||
|
.claude/skills/init-project/SKILL.md
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
||||||
@ -56,13 +71,23 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
|
|||||||
chmod +x .githooks/*
|
chmod +x .githooks/*
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 4-5. Hook 스크립트 갱신
|
||||||
|
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
|
||||||
|
```
|
||||||
|
.claude/scripts/on-pre-compact.sh
|
||||||
|
.claude/scripts/on-post-compact.sh
|
||||||
|
.claude/scripts/on-commit.sh
|
||||||
|
```
|
||||||
|
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
|
||||||
|
|
||||||
### 5. 로컬 버전 업데이트
|
### 5. 로컬 버전 업데이트
|
||||||
`.claude/workflow-version.json` 갱신:
|
`.claude/workflow-version.json` 갱신:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"applied_global_version": "새버전",
|
"applied_global_version": "새버전",
|
||||||
"applied_date": "오늘날짜",
|
"applied_date": "오늘날짜",
|
||||||
"project_type": "감지된타입"
|
"project_type": "감지된타입",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.1.0",
|
"applied_global_version": "1.2.0",
|
||||||
"applied_date": "2026-02-14",
|
"applied_date": "2026-02-14",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev"
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
|||||||
@ -22,11 +22,9 @@ fi
|
|||||||
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||||
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
||||||
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
||||||
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
|
|
||||||
|
|
||||||
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
if ! echo "$FIRST_LINE" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9._-]+\)|(\([^)]+\)))?: .{1,72}$'; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||||
|
|||||||
51
CLAUDE.md
51
CLAUDE.md
@ -38,7 +38,7 @@ npm run lint # ESLint 검사
|
|||||||
|
|
||||||
## 현재 구현 상태
|
## 현재 구현 상태
|
||||||
|
|
||||||
### 완료 (scaffold)
|
### 완료
|
||||||
- 프로젝트 초기화 (Vite + React + TypeScript + Tailwind CSS v4)
|
- 프로젝트 초기화 (Vite + React + TypeScript + Tailwind CSS v4)
|
||||||
- 인증 시스템 뼈대: AuthProvider, useAuth, ProtectedRoute, AdminRoute
|
- 인증 시스템 뼈대: AuthProvider, useAuth, ProtectedRoute, AdminRoute
|
||||||
- 페이지 뼈대: LoginPage, PendingPage, DeniedPage, HomePage, GuidePage
|
- 페이지 뼈대: LoginPage, PendingPage, DeniedPage, HomePage, GuidePage
|
||||||
@ -46,39 +46,21 @@ npm run lint # ESLint 검사
|
|||||||
- 라우팅 구성 (App.tsx): `/login`, `/pending`, `/denied`, `/`, `/dev/:section`, `/admin/*`
|
- 라우팅 구성 (App.tsx): `/login`, `/pending`, `/denied`, `/`, `/dev/:section`, `/admin/*`
|
||||||
- 유틸: api.ts (fetch 래퍼), navigation.ts (메뉴 + ant-style 패턴 매칭)
|
- 유틸: api.ts (fetch 래퍼), navigation.ts (메뉴 + ant-style 패턴 매칭)
|
||||||
- 타입 정의: User, Role, AuthResponse, NavItem, Issue
|
- 타입 정의: User, Role, AuthResponse, NavItem, Issue
|
||||||
|
- 공통 컴포넌트: CodeBlock, Alert, StepGuide, CopyButton
|
||||||
|
- 가이드 콘텐츠 7개 섹션 (실제 시스템 정보 검증 완료)
|
||||||
|
- 디자인 시스템: CSS 변수 기반 테마 (다크모드 준비)
|
||||||
- 빌드 검증: `tsc -b && vite build` 성공
|
- 빌드 검증: `tsc -b && vite build` 성공
|
||||||
|
|
||||||
### 미구현 (별도 세션에서 작업)
|
### 미구현 (별도 세션에서 작업)
|
||||||
아래 순서대로 구현 필요:
|
아래 순서대로 구현 필요:
|
||||||
|
|
||||||
#### 1단계: 공통 컴포넌트
|
#### 1단계: 관리자 페이지
|
||||||
- `src/components/common/CodeBlock.tsx` — 코드 블록 (highlight.js + 복사 버튼)
|
|
||||||
- `src/components/common/Alert.tsx` — 정보/경고/에러 알림 박스
|
|
||||||
- `src/components/common/StepGuide.tsx` — 단계별 가이드 UI
|
|
||||||
- `src/components/common/CopyButton.tsx` — 클립보드 복사 버튼
|
|
||||||
|
|
||||||
#### 2단계: 가이드 콘텐츠 (7개 섹션)
|
|
||||||
`src/content/` 디렉토리에 TSX 컴포넌트로 작성:
|
|
||||||
|
|
||||||
| 파일 | URL | 내용 |
|
|
||||||
|------|-----|------|
|
|
||||||
| DevEnvIntro.tsx | /dev/env-intro | 인프라 구성도, 서비스 카드, 도메인 테이블 |
|
|
||||||
| InitialSetup.tsx | /dev/initial-setup | SSH 키, Git 설정, SDKMAN/fnm, Claude Code 설치 |
|
|
||||||
| GiteaUsage.tsx | /dev/gitea-usage | Google OAuth 로그인, 리포 브라우징, 이슈/MR |
|
|
||||||
| NexusUsage.tsx | /dev/nexus-usage | Maven/Gradle/npm 프록시 설정, 패키지 배포 |
|
|
||||||
| GitWorkflow.tsx | /dev/git-workflow | 브랜치 전략, Conventional Commits, 3계층 정책 |
|
|
||||||
| ChatBotIntegration.tsx | /dev/chat-bot | 스페이스 생성, 봇 명령어, 알림 유형 |
|
|
||||||
| StartingProject.tsx | /dev/starting-project | 템플릿 비교, 리포 생성, /init-project |
|
|
||||||
|
|
||||||
GuidePage.tsx를 수정하여 section 파라미터에 따라 해당 콘텐츠 컴포넌트를 동적 렌더링.
|
|
||||||
|
|
||||||
#### 3단계: 관리자 페이지
|
|
||||||
- `src/pages/admin/UserManagement.tsx` — 사용자 목록, 승인/거절, 롤 배정
|
- `src/pages/admin/UserManagement.tsx` — 사용자 목록, 승인/거절, 롤 배정
|
||||||
- `src/pages/admin/RoleManagement.tsx` — 롤 CRUD
|
- `src/pages/admin/RoleManagement.tsx` — 롤 CRUD
|
||||||
- `src/pages/admin/PermissionManagement.tsx` — 롤별 URL 패턴 CRUD
|
- `src/pages/admin/PermissionManagement.tsx` — 롤별 URL 패턴 CRUD
|
||||||
- `src/pages/admin/StatsPage.tsx` — 통계 대시보드
|
- `src/pages/admin/StatsPage.tsx` — 통계 대시보드
|
||||||
|
|
||||||
#### 4단계: 다크모드 + 반응형
|
#### 2단계: 다크모드 + 반응형
|
||||||
- `src/hooks/useTheme.ts` — 다크/라이트 모드 토글 (localStorage 저장)
|
- `src/hooks/useTheme.ts` — 다크/라이트 모드 토글 (localStorage 저장)
|
||||||
- Header에 토글 버튼 추가
|
- Header에 토글 버튼 추가
|
||||||
- 모바일 반응형: 사이드바 접힘 (hamburger 메뉴)
|
- 모바일 반응형: 사이드바 접힘 (hamburger 메뉴)
|
||||||
@ -136,7 +118,7 @@ src/
|
|||||||
├── components/
|
├── components/
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ └── AppLayout.tsx ✅ (사이드바 + 메인 콘텐츠)
|
│ │ └── AppLayout.tsx ✅ (사이드바 + 메인 콘텐츠)
|
||||||
│ └── common/ ⬜ (CodeBlock, Alert, StepGuide, CopyButton)
|
│ └── common/ ✅ (CodeBlock, Alert, StepGuide, CopyButton)
|
||||||
├── pages/
|
├── pages/
|
||||||
│ ├── LoginPage.tsx ✅
|
│ ├── LoginPage.tsx ✅
|
||||||
│ ├── PendingPage.tsx ✅
|
│ ├── PendingPage.tsx ✅
|
||||||
@ -144,7 +126,7 @@ src/
|
|||||||
│ ├── HomePage.tsx ✅ (퀵링크 카드)
|
│ ├── HomePage.tsx ✅ (퀵링크 카드)
|
||||||
│ ├── GuidePage.tsx ✅ (section 기반 동적 렌더링 뼈대)
|
│ ├── GuidePage.tsx ✅ (section 기반 동적 렌더링 뼈대)
|
||||||
│ └── admin/ ⬜ (UserManagement, RoleManagement 등)
|
│ └── admin/ ⬜ (UserManagement, RoleManagement 등)
|
||||||
├── content/ ⬜ (7개 가이드 TSX)
|
├── content/ ✅ (7개 가이드 TSX — 실제 시스템 정보 검증 완료)
|
||||||
├── hooks/ ⬜ (useTheme, useScrollSpy)
|
├── hooks/ ⬜ (useTheme, useScrollSpy)
|
||||||
├── types/index.ts ✅ (User, Role, AuthResponse, NavItem, Issue)
|
├── types/index.ts ✅ (User, Role, AuthResponse, NavItem, Issue)
|
||||||
├── utils/
|
├── utils/
|
||||||
@ -185,6 +167,23 @@ POST /api/activity/track → { pagePath } → void
|
|||||||
GET /api/activity/login-history → LoginHistory[]
|
GET /api/activity/login-history → LoginHistory[]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 가이드 콘텐츠 정보 기준 (검증 완료)
|
||||||
|
|
||||||
|
가이드 페이지 콘텐츠는 실제 시스템 정보와 대조 검증 완료. 수정 시 참고:
|
||||||
|
|
||||||
|
| 항목 | 실제 값 |
|
||||||
|
|------|---------|
|
||||||
|
| Chat 봇 이름 | SI DevBot (GC Bot 아님) |
|
||||||
|
| 봇 명령어 | status, teams, link <팀이름>, help (@SI DevBot 멘션 방식) |
|
||||||
|
| 봇 소스 코드 | gc/gitea-chat-sync (Python Flask) |
|
||||||
|
| Java 패키지 경로 | com.gcsc (도메인 gcsc.co.kr 역순) |
|
||||||
|
| npm 배포 레포 | npm-hosted (npm-private 아님) |
|
||||||
|
| .githooks | commit-msg, post-checkout (pre-commit 없음) |
|
||||||
|
| 3계층 보호 | 1) 로컬 commit-msg hook, 2) 서버 pre-receive hook, 3) main 브랜치 보호 (MR+리뷰) |
|
||||||
|
| develop 브랜치 | push 허용 (pre-receive hook 검증 통과 필요), MR 필수 아님 |
|
||||||
|
| CI/CD | Gitea Actions 미구성 (예정) |
|
||||||
|
| Nexus 인증 | 프로젝트별 .npmrc / settings.xml에 포함 |
|
||||||
|
|
||||||
## UI 스타일 가이드
|
## UI 스타일 가이드
|
||||||
- Tailwind CSS v4 (index.css에 `@import "tailwindcss"`)
|
- Tailwind CSS v4 (index.css에 `@import "tailwindcss"`)
|
||||||
- 사이드바: 좌측 고정 w-64, 흰색 배경
|
- 사이드바: 좌측 고정 w-64, 흰색 배경
|
||||||
|
|||||||
51
src/App.tsx
51
src/App.tsx
@ -2,41 +2,48 @@ import { BrowserRouter, Route, Routes } from 'react-router';
|
|||||||
import { AuthProvider } from './auth/AuthProvider';
|
import { AuthProvider } from './auth/AuthProvider';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
import { AdminRoute } from './auth/AdminRoute';
|
import { AdminRoute } from './auth/AdminRoute';
|
||||||
|
import { ThemeProvider } from './hooks/ThemeProvider';
|
||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { PendingPage } from './pages/PendingPage';
|
import { PendingPage } from './pages/PendingPage';
|
||||||
import { DeniedPage } from './pages/DeniedPage';
|
import { DeniedPage } from './pages/DeniedPage';
|
||||||
import { HomePage } from './pages/HomePage';
|
import { HomePage } from './pages/HomePage';
|
||||||
import { GuidePage } from './pages/GuidePage';
|
import { GuidePage } from './pages/GuidePage';
|
||||||
|
import { UserManagement } from './pages/admin/UserManagement';
|
||||||
|
import { RoleManagement } from './pages/admin/RoleManagement';
|
||||||
|
import { PermissionManagement } from './pages/admin/PermissionManagement';
|
||||||
|
import { StatsPage } from './pages/admin/StatsPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
{/* Public */}
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
{/* Public */}
|
||||||
<Route path="/pending" element={<PendingPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/denied" element={<DeniedPage />} />
|
<Route path="/pending" element={<PendingPage />} />
|
||||||
|
<Route path="/denied" element={<DeniedPage />} />
|
||||||
|
|
||||||
{/* Protected */}
|
{/* Protected */}
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="/dev/:section" element={<GuidePage />} />
|
<Route path="/dev/:section" element={<GuidePage />} />
|
||||||
|
|
||||||
{/* Admin */}
|
{/* Admin */}
|
||||||
<Route element={<AdminRoute />}>
|
<Route element={<AdminRoute />}>
|
||||||
<Route path="/admin/users" element={<div className="p-8"><h1 className="text-2xl font-bold">사용자 관리</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
<Route path="/admin/users" element={<UserManagement />} />
|
||||||
<Route path="/admin/roles" element={<div className="p-8"><h1 className="text-2xl font-bold">롤 관리</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
<Route path="/admin/roles" element={<RoleManagement />} />
|
||||||
<Route path="/admin/permissions" element={<div className="p-8"><h1 className="text-2xl font-bold">권한 관리</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
<Route path="/admin/permissions" element={<PermissionManagement />} />
|
||||||
<Route path="/admin/stats" element={<div className="p-8"><h1 className="text-2xl font-bold">통계</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
<Route path="/admin/stats" element={<StatsPage />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Routes>
|
||||||
</Routes>
|
</BrowserRouter>
|
||||||
</BrowserRouter>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
src/auth/AuthContext.ts
Normal file
19
src/auth/AuthContext.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import type { User } from '../types';
|
||||||
|
|
||||||
|
export interface AuthContextValue {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (googleToken: string) => Promise<void>;
|
||||||
|
devLogin?: () => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue>({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
loading: true,
|
||||||
|
login: async () => {},
|
||||||
|
logout: () => {},
|
||||||
|
});
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@ -8,34 +7,52 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import type { AuthResponse, User } from '../types';
|
import type { AuthResponse, User } from '../types';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { AuthContext } from './AuthContext';
|
||||||
|
|
||||||
interface AuthContextValue {
|
const DEV_MOCK_USER: User = {
|
||||||
user: User | null;
|
id: 1,
|
||||||
token: string | null;
|
email: 'htlee@gcsc.co.kr',
|
||||||
loading: boolean;
|
name: '이현태 (DEV)',
|
||||||
login: (googleToken: string) => Promise<void>;
|
avatarUrl: null,
|
||||||
logout: () => void;
|
status: 'ACTIVE',
|
||||||
|
isAdmin: true,
|
||||||
|
roles: [{ id: 1, name: 'ADMIN', description: '관리자', urlPatterns: ['/**'] }],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastLoginAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function isDevMockSession(): boolean {
|
||||||
|
return import.meta.env.DEV && localStorage.getItem('dev-user') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue>({
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
loading: true,
|
|
||||||
login: async () => {},
|
|
||||||
logout: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(() =>
|
||||||
|
isDevMockSession() ? DEV_MOCK_USER : null,
|
||||||
|
);
|
||||||
const [token, setToken] = useState<string | null>(
|
const [token, setToken] = useState<string | null>(
|
||||||
() => localStorage.getItem('token'),
|
() => localStorage.getItem('token'),
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(true);
|
const [initialized, setInitialized] = useState(
|
||||||
|
() => isDevMockSession() || !localStorage.getItem('token'),
|
||||||
|
);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
|
const hadToken = !!localStorage.getItem('token') && !isDevMockSession();
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('dev-user');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
if (hadToken) {
|
||||||
|
api.post('/auth/logout').catch(() => {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const devLogin = useCallback(() => {
|
||||||
|
localStorage.setItem('dev-user', 'true');
|
||||||
|
localStorage.setItem('token', 'dev-mock-token');
|
||||||
|
setToken('dev-mock-token');
|
||||||
|
setUser(DEV_MOCK_USER);
|
||||||
|
setInitialized(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = useCallback(async (googleToken: string) => {
|
const login = useCallback(async (googleToken: string) => {
|
||||||
@ -48,22 +65,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token || isDevMockSession()) return;
|
||||||
setLoading(false);
|
|
||||||
return;
|
let cancelled = false;
|
||||||
}
|
|
||||||
api
|
api
|
||||||
.get<User>('/auth/me')
|
.get<User>('/auth/me')
|
||||||
.then(setUser)
|
.then((data) => {
|
||||||
.catch(() => {
|
if (!cancelled) setUser(data);
|
||||||
logout();
|
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.catch(() => {
|
||||||
|
if (!cancelled) logout();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setInitialized(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [token, logout]);
|
}, [token, logout]);
|
||||||
|
|
||||||
|
const loading = !initialized;
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ user, token, loading, login, logout }),
|
() => ({ user, token, loading, login, devLogin: import.meta.env.DEV ? devLogin : undefined, logout }),
|
||||||
[user, token, loading, login, logout],
|
[user, token, loading, login, devLogin, logout],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@ -6,8 +6,8 @@ export function ProtectedRoute() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
|
<div className="animate-spin h-8 w-8 border-4 border-accent border-t-transparent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { AuthContext } from './AuthProvider';
|
import { AuthContext } from './AuthContext';
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
return useContext(AuthContext);
|
return useContext(AuthContext);
|
||||||
|
|||||||
51
src/components/common/Alert.tsx
Normal file
51
src/components/common/Alert.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AlertProps {
|
||||||
|
type: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<AlertProps['type'], ReactNode> = {
|
||||||
|
info: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
success: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLES: Record<AlertProps['type'], string> = {
|
||||||
|
info: 'bg-info/10 border-info/30 text-info',
|
||||||
|
warning: 'bg-warning/10 border-warning/30 text-warning',
|
||||||
|
error: 'bg-danger/10 border-danger/30 text-danger',
|
||||||
|
success: 'bg-success/10 border-success/30 text-success',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Alert({ type, title, children }: AlertProps) {
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-lg p-4 text-sm my-4 ${STYLES[type]}`}>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="flex-shrink-0 mt-0.5">{ICONS[type]}</span>
|
||||||
|
<div>
|
||||||
|
{title && <p className="font-semibold mb-1">{title}</p>}
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/common/CodeBlock.tsx
Normal file
39
src/components/common/CodeBlock.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
|
||||||
|
interface CodeBlockProps {
|
||||||
|
code: string;
|
||||||
|
language?: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlock({ code, language, filename }: CodeBlockProps) {
|
||||||
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeRef.current) {
|
||||||
|
codeRef.current.removeAttribute('data-highlighted');
|
||||||
|
hljs.highlightElement(codeRef.current);
|
||||||
|
}
|
||||||
|
}, [code, language]);
|
||||||
|
|
||||||
|
const trimmedCode = code.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group rounded-lg overflow-hidden border border-gray-700 bg-[#282c34] my-4">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/60 text-gray-400 text-xs border-b border-gray-700">
|
||||||
|
<span>{filename || language || ''}</span>
|
||||||
|
<CopyButton text={trimmedCode} />
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm leading-relaxed m-0">
|
||||||
|
<code
|
||||||
|
ref={codeRef}
|
||||||
|
className={language ? `language-${language}` : ''}
|
||||||
|
>
|
||||||
|
{trimmedCode}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/common/CopyButton.tsx
Normal file
39
src/components/common/CopyButton.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ text, className = '' }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||||
|
copied
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
} ${className}`}
|
||||||
|
title="클립보드에 복사"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{copied ? '복사됨' : '복사'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/common/StepGuide.tsx
Normal file
33
src/components/common/StepGuide.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
title: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepGuideProps {
|
||||||
|
steps: Step[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepGuide({ steps }: StepGuideProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0 my-6">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={index} className="flex gap-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent text-white flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className="w-0.5 flex-1 bg-accent/20 mt-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pb-8 flex-1 min-w-0">
|
||||||
|
<h4 className="font-semibold text-text-primary mb-2">{step.title}</h4>
|
||||||
|
<div className="text-sm text-text-secondary">{step.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/common/TableOfContents.tsx
Normal file
95
src/components/common/TableOfContents.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
import { useScrollSpy } from '../../hooks/useScrollSpy';
|
||||||
|
|
||||||
|
interface TocItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeadingsSnapshot(): TocItem[] {
|
||||||
|
const headings = document.querySelectorAll('h2[id], h3[id]');
|
||||||
|
const tocItems: TocItem[] = [];
|
||||||
|
headings.forEach((heading) => {
|
||||||
|
tocItems.push({
|
||||||
|
id: heading.id,
|
||||||
|
text: heading.textContent || '',
|
||||||
|
level: heading.tagName === 'H2' ? 2 : 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return tocItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedItems: TocItem[] = [];
|
||||||
|
let cachedKey = '';
|
||||||
|
|
||||||
|
function subscribe(callback: () => void) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const fresh = getHeadingsSnapshot();
|
||||||
|
const key = fresh.map((i) => i.id).join(',');
|
||||||
|
if (key !== cachedKey) {
|
||||||
|
cachedKey = key;
|
||||||
|
cachedItems = fresh;
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// initial scan
|
||||||
|
const initial = getHeadingsSnapshot();
|
||||||
|
cachedKey = initial.map((i) => i.id).join(',');
|
||||||
|
cachedItems = initial;
|
||||||
|
callback();
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot() {
|
||||||
|
return cachedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableOfContents() {
|
||||||
|
const items = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
const activeId = useScrollSpy('h2[id], h3[id]');
|
||||||
|
|
||||||
|
// Memoize to prevent unnecessary re-renders
|
||||||
|
const stableItems = useMemo(() => items, [items]);
|
||||||
|
|
||||||
|
// Re-scan when route changes (items empty after navigation)
|
||||||
|
useEffect(() => {
|
||||||
|
const fresh = getHeadingsSnapshot();
|
||||||
|
const key = fresh.map((i) => i.id).join(',');
|
||||||
|
if (key !== cachedKey) {
|
||||||
|
cachedKey = key;
|
||||||
|
cachedItems = fresh;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stableItems.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="hidden xl:block fixed right-8 top-24 w-56">
|
||||||
|
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||||
|
목차
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-sm border-l border-border-default">
|
||||||
|
{stableItems.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a
|
||||||
|
href={`#${item.id}`}
|
||||||
|
className={`block py-1 transition-colors ${
|
||||||
|
item.level === 3 ? 'pl-6' : 'pl-3'
|
||||||
|
} ${
|
||||||
|
activeId === item.id
|
||||||
|
? 'text-accent border-l-2 border-accent -ml-px'
|
||||||
|
: 'text-text-muted hover:text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,98 +1,165 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { NavLink, Outlet } from 'react-router';
|
import { NavLink, Outlet } from 'react-router';
|
||||||
import { useAuth } from '../../auth/useAuth';
|
import { useAuth } from '../../auth/useAuth';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import { DEV_NAV, ADMIN_NAV } from '../../utils/navigation';
|
import { DEV_NAV, ADMIN_NAV } from '../../utils/navigation';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
|
||||||
|
setTheme(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeIcon =
|
||||||
|
theme === 'light' ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
) : theme === 'dark' ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const themeLabel = theme === 'light' ? '라이트' : theme === 'dark' ? '다크' : '시스템';
|
||||||
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<>
|
||||||
|
<div className="p-5 border-b border-white/10">
|
||||||
|
<a href="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-sidebar-active-bg rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-bold">GC</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-white">개발자 가이드</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-4 overflow-y-auto">
|
||||||
|
<p className="text-xs font-semibold text-sidebar-text/60 uppercase tracking-wider mb-2">
|
||||||
|
가이드
|
||||||
|
</p>
|
||||||
|
{DEV_NAV.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-sidebar-active-bg text-sidebar-active-text font-medium'
|
||||||
|
: 'text-sidebar-text hover:bg-white/10'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold text-sidebar-text/60 uppercase tracking-wider mt-6 mb-2">
|
||||||
|
관리
|
||||||
|
</p>
|
||||||
|
{ADMIN_NAV.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-sidebar-active-bg text-sidebar-active-text font-medium'
|
||||||
|
: 'text-sidebar-text hover:bg-white/10'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={cycleTheme}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-sidebar-text hover:bg-white/10 transition-colors cursor-pointer mb-3"
|
||||||
|
title={`현재: ${themeLabel}`}
|
||||||
|
>
|
||||||
|
{themeIcon}
|
||||||
|
<span>{themeLabel} 모드</span>
|
||||||
|
</button>
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="w-8 h-8 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center text-xs font-medium text-white">
|
||||||
|
{user.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{user.name}</p>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-xs text-sidebar-text/70 hover:text-danger cursor-pointer"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex">
|
<div className="min-h-screen bg-bg-primary flex">
|
||||||
{/* Sidebar */}
|
{/* Mobile overlay */}
|
||||||
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
|
{sidebarOpen && (
|
||||||
<div className="p-5 border-b border-gray-100">
|
<div
|
||||||
<a href="/" className="flex items-center gap-2">
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
onClick={() => setSidebarOpen(false)}
|
||||||
<span className="text-white text-sm font-bold">GC</span>
|
/>
|
||||||
</div>
|
)}
|
||||||
<span className="font-semibold text-gray-900">개발자 가이드</span>
|
|
||||||
</a>
|
{/* Sidebar - desktop */}
|
||||||
</div>
|
<aside className="hidden lg:flex w-64 bg-sidebar-bg flex-col flex-shrink-0 sticky top-0 h-screen">
|
||||||
<nav className="flex-1 p-4 overflow-y-auto">
|
{sidebarContent}
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
|
</aside>
|
||||||
가이드
|
|
||||||
</p>
|
{/* Sidebar - mobile */}
|
||||||
{DEV_NAV.map((item) => (
|
<aside
|
||||||
<NavLink
|
className={`fixed inset-y-0 left-0 z-50 w-64 bg-sidebar-bg flex flex-col transform transition-transform duration-200 lg:hidden ${
|
||||||
key={item.path}
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
to={item.path}
|
}`}
|
||||||
className={({ isActive }) =>
|
>
|
||||||
`block px-3 py-2 rounded-lg text-sm mb-0.5 ${
|
{sidebarContent}
|
||||||
isActive
|
|
||||||
? 'bg-blue-50 text-blue-700 font-medium'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
{user?.isAdmin && (
|
|
||||||
<>
|
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mt-6 mb-2">
|
|
||||||
관리
|
|
||||||
</p>
|
|
||||||
{ADMIN_NAV.map((item) => (
|
|
||||||
<NavLink
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block px-3 py-2 rounded-lg text-sm mb-0.5 ${
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-50 text-blue-700 font-medium'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
<div className="p-4 border-t border-gray-100">
|
|
||||||
{user && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{user.avatarUrl ? (
|
|
||||||
<img
|
|
||||||
src={user.avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="w-8 h-8 rounded-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center text-xs font-medium text-gray-600">
|
|
||||||
{user.name[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">
|
|
||||||
{user.name}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer"
|
|
||||||
>
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-y-auto">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<Outlet />
|
{/* Mobile header */}
|
||||||
</main>
|
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-surface border-b border-border-default">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="p-1.5 rounded-lg text-text-secondary hover:bg-bg-tertiary cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="font-semibold text-text-primary text-sm">GC 개발자 가이드</span>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
269
src/content/ChatBotIntegration.tsx
Normal file
269
src/content/ChatBotIntegration.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function ChatBotIntegration() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Chat 봇 연동</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
Google Chat 스페이스에서 SI DevBot을 통한 Gitea 알림 수신 및 팀 연결 방법을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 개요 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">개요</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
<strong>SI DevBot</strong>은 Gitea와 Google Chat을 연동하는 봇입니다.
|
||||||
|
Gitea에서 발생하는 이벤트(Push, MR, 이슈 등)를 Chat 스페이스로 자동 알림하고,
|
||||||
|
간단한 명령어로 상태를 확인할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5 mb-6">
|
||||||
|
<div className="font-mono text-sm space-y-1.5 text-text-secondary">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-accent font-semibold">Gitea Webhook</span>
|
||||||
|
<span className="text-text-muted">→</span>
|
||||||
|
<span>gitea-chat-sync</span>
|
||||||
|
<span className="text-text-muted">→</span>
|
||||||
|
<span className="text-accent font-semibold">Google Chat API</span>
|
||||||
|
<span className="text-text-muted">→</span>
|
||||||
|
<span>스페이스 알림</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스페이스 설정 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">스페이스 설정</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'Google Chat 스페이스 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Google Chat에서 <strong>스페이스 만들기</strong>를 클릭합니다.
|
||||||
|
스페이스 이름을 <code className="bg-bg-tertiary px-1 rounded">[GC] 팀이름</code> 형식으로
|
||||||
|
생성하면 봇이 자동으로 Gitea 팀과 매칭합니다.
|
||||||
|
(예: <code className="bg-bg-tertiary px-1 rounded">[GC] developers</code>)
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SI DevBot 추가',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
스페이스 설정 → <strong>앱 및 통합</strong> → <strong>SI DevBot</strong>을
|
||||||
|
검색하여 추가합니다. 봇이 스페이스에 참여하면 환영 메시지와 함께 사용 가능한 명령어를 안내합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '팀 연결 확인',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">
|
||||||
|
스페이스 이름이 <code className="bg-bg-tertiary px-1 rounded">[GC] 팀이름</code> 형식이면
|
||||||
|
자동 연결됩니다. 수동으로 연결하려면{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">link</code> 명령어를 사용합니다.
|
||||||
|
</p>
|
||||||
|
<Alert type="info">
|
||||||
|
Gitea 조직 Webhook은 관리자가 설정합니다. 개별 리포지토리 Webhook 설정은 불필요합니다.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 봇 명령어 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">봇 명령어</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
스페이스에서 <code className="bg-bg-tertiary px-1 rounded">@SI DevBot</code>을 멘션하여 명령어를 사용합니다.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">명령어</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">설명</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">예시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">status</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
Gitea/Chat 연결 상태 및 팀-스페이스 매핑 현황 확인
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">@SI DevBot status</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">teams</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
Gitea 조직의 팀 목록 및 스페이스 연결 상태 조회
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">@SI DevBot teams</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">{'link <팀이름>'}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
현재 스페이스를 Gitea 팀과 수동 연결
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
||||||
|
@SI DevBot link developers
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">help</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">사용 가능한 명령어 목록 표시</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">@SI DevBot help</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알림 유형 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">알림 유형</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea 조직 Webhook을 통해 다음 이벤트가 연결된 스페이스로 자동 전송됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: '\u{1F4DD}',
|
||||||
|
title: 'Push 알림',
|
||||||
|
desc: '브랜치에 새 커밋이 푸시되면 알림 (커밋 3개까지 상세 표시)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F500}',
|
||||||
|
title: 'MR 생성/머지',
|
||||||
|
desc: 'MR이 생성, 승인, 머지(Squash)될 때 알림',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F41B}',
|
||||||
|
title: '이슈 변경',
|
||||||
|
desc: '이슈 생성, 닫힘, 재오픈 시 알림',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F4AC}',
|
||||||
|
title: '댓글 알림',
|
||||||
|
desc: '이슈/MR에 댓글이 추가되면 알림 (최대 200자)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F4E6}',
|
||||||
|
title: '저장소 생성/삭제',
|
||||||
|
desc: '새 저장소가 생성되거나 삭제될 때 알림',
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
className="bg-surface border border-border-default rounded-lg p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-xl flex-shrink-0">{item.icon}</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">{item.title}</h4>
|
||||||
|
<p className="text-xs text-text-secondary mt-0.5">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 팀-스페이스 매핑 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">팀-스페이스 매핑</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea 팀과 Chat 스페이스가 연결되면 해당 팀의 리포지토리 이벤트가 스페이스로 전송됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-1">자동 매칭</h4>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
스페이스 이름이 <code className="bg-bg-tertiary px-1 rounded">[GC] 팀이름</code> 형식이면
|
||||||
|
봇 추가 시 자동으로 Gitea 팀과 연결됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-1">수동 연결</h4>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">@SI DevBot link developers</code>{' '}
|
||||||
|
명령어로 현재 스페이스를 원하는 Gitea 팀에 수동 연결할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-1">주기적 동기화</h4>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
30분마다 Gitea 팀 목록과 Chat 스페이스 매핑을 자동으로 동기화합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 봇 관리 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">봇 관리 (관리자용)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
봇의 명령어나 알림 로직을 수정하려면 소스 코드를 변경하고 서버에 재배포합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">소스 코드</h3>
|
||||||
|
<p className="text-text-secondary mb-3">
|
||||||
|
Gitea <code className="bg-bg-tertiary px-1 rounded">gc/gitea-chat-sync</code> 리포지토리
|
||||||
|
(Python Flask)
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto mb-4">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">파일</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">역할</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">chat_event_handler.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
봇 명령어 처리 (status, teams, link, help)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">webhook_handler.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
Gitea 이벤트 → Chat 알림 변환
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">sync_engine.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">팀-스페이스 동기화 엔진</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">reconciler.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">30분 주기 매핑 동기화</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">config.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">환경변수 설정</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">배포 방법</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
filename="서버 배포 절차"
|
||||||
|
code={`# 1. Gitea에서 소스 수정 후 develop push → MR → main 머지
|
||||||
|
|
||||||
|
# 2. 서버 접속 후 최신 소스 pull
|
||||||
|
ssh root@211.208.115.83
|
||||||
|
cd /devdata/services/gitea-chat-sync
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 3. Docker 이미지 재빌드 및 재시작
|
||||||
|
docker-compose -f /devdata/services/docker-compose.yml up -d --build gitea-chat-sync
|
||||||
|
|
||||||
|
# 4. 로그 확인
|
||||||
|
docker logs -f gitea-chat-sync --tail 50`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert type="warning" title="현재 제한사항">
|
||||||
|
현재 Phase 2-A (단방향)로 운영 중입니다. Gitea → Chat 알림 전송만 가능하며, Chat에서
|
||||||
|
Gitea로의 양방향 동기화(스페이스 자동 생성, 멤버 동기화)는 관리자 승인 후 활성화 예정입니다.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
476
src/content/DesignSystem.tsx
Normal file
476
src/content/DesignSystem.tsx
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
|
||||||
|
export default function DesignSystem() {
|
||||||
|
const [activeTab, setActiveTab] = useState('buttons');
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'buttons', label: '버튼' },
|
||||||
|
{ id: 'cards', label: '카드' },
|
||||||
|
{ id: 'alerts', label: '알림' },
|
||||||
|
{ id: 'badges', label: '배지' },
|
||||||
|
{ id: 'forms', label: '폼' },
|
||||||
|
{ id: 'tables', label: '테이블' },
|
||||||
|
{ id: 'theme', label: '테마 커스텀' },
|
||||||
|
{ id: 'future', label: '향후 확장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">디자인 시스템</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
프로젝트에서 사용하는 UI 컴포넌트 쇼케이스입니다. 테마 전환 시 실시간으로 반영됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<div className="flex gap-1 overflow-x-auto pb-2 mb-8 border-b border-border-default">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-t-lg cursor-pointer transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'text-text-secondary hover:bg-bg-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
{activeTab === 'buttons' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">버튼</h2>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">기본 버튼</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover">Primary</button>
|
||||||
|
<button className="px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg text-sm font-medium hover:bg-border-default">Secondary</button>
|
||||||
|
<button className="px-4 py-2 border border-border-default text-text-secondary rounded-lg text-sm font-medium hover:bg-bg-tertiary">Outline</button>
|
||||||
|
<button className="px-4 py-2 text-link text-sm font-medium hover:text-accent-hover">Ghost</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover">
|
||||||
|
Primary
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg text-sm font-medium hover:bg-border-default">
|
||||||
|
Secondary
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 border border-border-default text-text-secondary rounded-lg text-sm font-medium hover:bg-bg-tertiary">
|
||||||
|
Outline
|
||||||
|
</button>`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">상태별 버튼</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button className="px-4 py-2 bg-success text-white rounded-lg text-sm font-medium">Success</button>
|
||||||
|
<button className="px-4 py-2 bg-warning text-white rounded-lg text-sm font-medium">Warning</button>
|
||||||
|
<button className="px-4 py-2 bg-danger text-white rounded-lg text-sm font-medium">Danger</button>
|
||||||
|
<button className="px-4 py-2 bg-info text-white rounded-lg text-sm font-medium">Info</button>
|
||||||
|
<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium opacity-50 cursor-not-allowed">Disabled</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">크기 변형</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button className="px-2.5 py-1 bg-accent text-white rounded text-xs font-medium hover:bg-accent-hover">Small</button>
|
||||||
|
<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover">Medium</button>
|
||||||
|
<button className="px-6 py-3 bg-accent text-white rounded-lg text-base font-medium hover:bg-accent-hover">Large</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
{activeTab === 'cards' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">카드</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-2">기본 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">카드 본문 내용이 들어갑니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5 hover:border-accent hover:shadow-md transition">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-2">호버 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">마우스를 올려보세요.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-soft border border-accent/20 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-accent mb-2">강조 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">accent-soft 배경을 사용합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl overflow-hidden">
|
||||||
|
<div className="px-5 py-3 bg-bg-tertiary border-b border-border-default">
|
||||||
|
<h3 className="font-semibold text-text-primary text-sm">헤더 카드</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<p className="text-sm text-text-secondary">헤더가 있는 카드입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-2">기본 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">카드 본문 내용</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-accent-soft border border-accent/20 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-accent mb-2">강조 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">accent-soft 배경</p>
|
||||||
|
</div>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 알림 */}
|
||||||
|
{activeTab === 'alerts' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">알림</h2>
|
||||||
|
<Alert type="info" title="정보">이것은 정보 알림입니다.</Alert>
|
||||||
|
<Alert type="success" title="성공">작업이 성공적으로 완료되었습니다.</Alert>
|
||||||
|
<Alert type="warning" title="주의">이 작업은 되돌릴 수 없습니다.</Alert>
|
||||||
|
<Alert type="error" title="에러">요청을 처리하는 중 오류가 발생했습니다.</Alert>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`import { Alert } from '../components/common/Alert';
|
||||||
|
|
||||||
|
<Alert type="info" title="정보">이것은 정보 알림입니다.</Alert>
|
||||||
|
<Alert type="success" title="성공">작업이 성공적으로 완료되었습니다.</Alert>
|
||||||
|
<Alert type="warning" title="주의">이 작업은 되돌릴 수 없습니다.</Alert>
|
||||||
|
<Alert type="error" title="에러">요청 처리 중 오류가 발생했습니다.</Alert>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 배지 */}
|
||||||
|
{activeTab === 'badges' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">배지</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="px-2.5 py-0.5 bg-accent/10 text-accent rounded-full text-xs font-medium">기본</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-success/10 text-success rounded-full text-xs font-medium">성공</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-warning/10 text-warning rounded-full text-xs font-medium">주의</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-danger/10 text-danger rounded-full text-xs font-medium">위험</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-info/10 text-info rounded-full text-xs font-medium">정보</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-bg-tertiary text-text-muted rounded-full text-xs font-medium">비활성</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<span className="px-2.5 py-0.5 bg-accent/10 text-accent rounded-full text-xs font-medium">
|
||||||
|
기본
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-success/10 text-success rounded-full text-xs font-medium">
|
||||||
|
성공
|
||||||
|
</span>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
{activeTab === 'forms' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">폼 요소</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 space-y-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">텍스트 입력</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="입력하세요..."
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">셀렉트</label>
|
||||||
|
<select className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent">
|
||||||
|
<option>옵션 1</option>
|
||||||
|
<option>옵션 2</option>
|
||||||
|
<option>옵션 3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">텍스트영역</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="내용을 입력하세요..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" className="rounded accent-accent" defaultChecked />
|
||||||
|
<span className="text-sm text-text-primary">체크박스</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="demo" className="accent-accent" defaultChecked />
|
||||||
|
<span className="text-sm text-text-primary">라디오 A</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="demo" className="accent-accent" />
|
||||||
|
<span className="text-sm text-text-primary">라디오 B</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<input
|
||||||
|
type="text"
|
||||||
|
placeholder="입력하세요..."
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm
|
||||||
|
bg-bg-primary text-text-primary
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{activeTab === 'tables' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">테이블</h2>
|
||||||
|
<div className="overflow-x-auto mb-4">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">이름</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">이메일</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">홍길동</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">hong@gcsc.co.kr</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 bg-success/10 text-success rounded-full text-xs font-medium">활성</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button className="text-link hover:text-accent-hover text-sm">편집</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">김철수</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">kim@gcsc.co.kr</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 bg-warning/10 text-warning rounded-full text-xs font-medium">대기</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button className="text-link hover:text-accent-hover text-sm">편집</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">헤더</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">데이터</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테마 커스텀 */}
|
||||||
|
{activeTab === 'theme' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">커스텀 테마 구성</h2>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">테마 시스템 구조</h3>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
이 프로젝트는 CSS 변수 기반 테마 시스템을 사용합니다.
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">data-theme</code> 속성으로 테마를 전환하고,
|
||||||
|
시맨틱 변수를 통해 일관된 색상을 유지합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="css"
|
||||||
|
filename="index.css — 구조"
|
||||||
|
code={`/* 1. Tailwind에 시맨틱 색상 등록 */
|
||||||
|
@theme {
|
||||||
|
--color-bg-primary: var(--theme-bg-primary);
|
||||||
|
--color-accent: var(--theme-accent);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 테마별 실제 색상값 정의 */
|
||||||
|
:root, [data-theme="light"] {
|
||||||
|
--theme-bg-primary: #f8f9fa;
|
||||||
|
--theme-accent: #213079;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--theme-bg-primary: #011C2F;
|
||||||
|
--theme-accent: #02908B;
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-8 mb-3">새 테마 추가하기</h3>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
새로운 테마를 추가하려면 CSS에 <code className="bg-bg-tertiary px-1 rounded">[data-theme="이름"]</code> 블록을 정의하면 됩니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="css"
|
||||||
|
filename="index.css — 커스텀 테마 추가 예시"
|
||||||
|
code={`/* Ocean 테마 */
|
||||||
|
[data-theme="ocean"] {
|
||||||
|
--theme-bg-primary: #0a192f;
|
||||||
|
--theme-bg-secondary: #112240;
|
||||||
|
--theme-bg-tertiary: #1d3557;
|
||||||
|
--theme-text-primary: #ccd6f6;
|
||||||
|
--theme-text-secondary: #8892b0;
|
||||||
|
--theme-text-muted: #495670;
|
||||||
|
--theme-border-default: #233554;
|
||||||
|
--theme-border-subtle: rgba(255, 255, 255, 0.08);
|
||||||
|
--theme-accent: #64ffda;
|
||||||
|
--theme-accent-hover: #4fd1b5;
|
||||||
|
--theme-accent-soft: rgba(100, 255, 218, 0.1);
|
||||||
|
--theme-surface: #112240;
|
||||||
|
--theme-sidebar-bg: #0a192f;
|
||||||
|
--theme-sidebar-active-bg: #64ffda;
|
||||||
|
--theme-sidebar-active-text: #0a192f;
|
||||||
|
--theme-sidebar-text: #8892b0;
|
||||||
|
--theme-info: #57cbff;
|
||||||
|
--theme-success: #64ffda;
|
||||||
|
--theme-warning: #ffd166;
|
||||||
|
--theme-danger: #ff6b6b;
|
||||||
|
--theme-link: #64ffda;
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-8 mb-3">시맨틱 색상 변수 레퍼런스</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">변수</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">Tailwind 클래스</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">용도</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{[
|
||||||
|
{ var: '--theme-bg-primary', cls: 'bg-bg-primary', use: '페이지 배경' },
|
||||||
|
{ var: '--theme-bg-secondary', cls: 'bg-bg-secondary', use: '보조 배경' },
|
||||||
|
{ var: '--theme-bg-tertiary', cls: 'bg-bg-tertiary', use: 'UI 요소 배경' },
|
||||||
|
{ var: '--theme-text-primary', cls: 'text-text-primary', use: '주 텍스트' },
|
||||||
|
{ var: '--theme-text-secondary', cls: 'text-text-secondary', use: '보조 텍스트' },
|
||||||
|
{ var: '--theme-text-muted', cls: 'text-text-muted', use: '비활성 텍스트' },
|
||||||
|
{ var: '--theme-accent', cls: 'bg-accent / text-accent', use: '주 강조색' },
|
||||||
|
{ var: '--theme-accent-hover', cls: 'hover:bg-accent-hover', use: '호버 강조색' },
|
||||||
|
{ var: '--theme-accent-soft', cls: 'bg-accent-soft', use: '연한 강조 배경' },
|
||||||
|
{ var: '--theme-surface', cls: 'bg-surface', use: '카드/패널 배경' },
|
||||||
|
{ var: '--theme-border-default', cls: 'border-border-default', use: '기본 테두리' },
|
||||||
|
{ var: '--theme-link', cls: 'text-link', use: '링크 텍스트' },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.var}>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs text-accent">{row.var}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs text-text-secondary">{row.cls}</td>
|
||||||
|
<td className="px-4 py-2 text-text-secondary">{row.use}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" title="테마 전환 확인">
|
||||||
|
사이드바 하단의 테마 토글 버튼으로 라이트/다크/시스템 모드를 전환하면
|
||||||
|
이 페이지의 모든 컴포넌트가 실시간으로 반영됩니다.
|
||||||
|
</Alert>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 향후 확장 */}
|
||||||
|
{activeTab === 'future' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">향후 확장 계획</h2>
|
||||||
|
|
||||||
|
<Alert type="info">
|
||||||
|
현재는 프로젝트 내부 네이티브 구현이며, 향후 공통 UI 컴포넌트 프로젝트로 분리할 계획입니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">공통 UI 컴포넌트 프로젝트</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5 mb-4">
|
||||||
|
<div className="space-y-3 text-sm text-text-secondary">
|
||||||
|
<p>
|
||||||
|
<strong className="text-text-primary">목표:</strong> 프론트엔드 퍼블리셔가 담당하는 별도 프로젝트로 공통 UI 컴포넌트를 관리
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-text-primary">배포:</strong> 컴포넌트별 시맨틱 버저닝으로 Nexus npm 레지스트리에 퍼블리시
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-text-primary">사용:</strong> 프로젝트 템플릿에서 이름+버전으로 import하여 사용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">컴포넌트 네이밍/버저닝 규칙</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
filename="package.json 예시"
|
||||||
|
code={`{
|
||||||
|
"name": "@gc/ui-components",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"exports": {
|
||||||
|
"./Button": "./dist/Button.js",
|
||||||
|
"./Card": "./dist/Card.js",
|
||||||
|
"./Alert": "./dist/Alert.js",
|
||||||
|
"./Table": "./dist/Table.js"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">프로젝트에서 사용</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`import { Button } from '@gc/ui-components/Button';
|
||||||
|
import { Card } from '@gc/ui-components/Card';
|
||||||
|
import { Alert } from '@gc/ui-components/Alert';
|
||||||
|
|
||||||
|
function MyPage() {
|
||||||
|
return (
|
||||||
|
<Card title="사용자 목록">
|
||||||
|
<Alert type="info">로딩 중입니다.</Alert>
|
||||||
|
<Button variant="primary" onClick={handleSubmit}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">자동 문서 연동</h3>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
공통 컴포넌트 프로젝트가 완성되면 gc-guide에서 레지스트리 정보를 읽어
|
||||||
|
각 컴포넌트별 버전별 퍼블리시 문서를 자동으로 생성/연동할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="font-mono text-sm space-y-1.5 text-text-secondary">
|
||||||
|
<div><span className="text-accent">@gc/ui-components</span></div>
|
||||||
|
<div className="pl-4">v1.2.0 (latest) — Button, Card, Alert, Table, Badge</div>
|
||||||
|
<div className="pl-4">v1.1.0 — Button, Card, Alert, Table</div>
|
||||||
|
<div className="pl-4">v1.0.0 — Button, Card, Alert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/content/DevEnvIntro.tsx
Normal file
116
src/content/DevEnvIntro.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
|
||||||
|
export default function DevEnvIntro() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">개발환경 소개</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
GC SI 개발팀이 사용하는 인프라 구성과 핵심 서비스를 소개합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 인프라 구성도 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">인프라 구성</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="bg-accent-soft rounded-lg p-4">
|
||||||
|
<div className="text-2xl mb-2">🖥️</div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">개발자 로컬</h4>
|
||||||
|
<p className="text-xs text-text-muted mt-1">IDE + Git + SDK</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-soft rounded-lg p-4">
|
||||||
|
<div className="text-2xl mb-2">🔄</div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">CI/CD</h4>
|
||||||
|
<p className="text-xs text-text-muted mt-1">Gitea Actions</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-soft rounded-lg p-4">
|
||||||
|
<div className="text-2xl mb-2">🚀</div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">운영 서버</h4>
|
||||||
|
<p className="text-xs text-text-muted mt-1">Docker + Nginx</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center mt-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
|
<span>Push</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span>Build & Test</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span>Deploy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 핵심 서비스 카드 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">핵심 서비스</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="w-10 h-10 bg-accent-soft rounded-lg flex items-center justify-center mb-3">
|
||||||
|
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-text-primary mb-1">Gitea</h3>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
소스 코드 관리, 이슈 트래킹, MR 기반 코드 리뷰
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="w-10 h-10 bg-accent-soft rounded-lg flex items-center justify-center mb-3">
|
||||||
|
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-text-primary mb-1">Nexus</h3>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Maven, npm 프록시 저장소 및 프라이빗 패키지 배포
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="w-10 h-10 bg-accent-soft rounded-lg flex items-center justify-center mb-3">
|
||||||
|
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-text-primary mb-1">SI DevBot</h3>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Google Chat 연동 Gitea 알림
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도메인 테이블 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">서비스 도메인</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">서비스</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">URL</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">용도</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">Gitea</td>
|
||||||
|
<td className="px-4 py-3"><a href="https://gitea.gc-si.dev" target="_blank" rel="noopener noreferrer" className="text-link hover:underline">gitea.gc-si.dev</a></td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">Git 저장소</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">Nexus</td>
|
||||||
|
<td className="px-4 py-3"><a href="https://nexus.gc-si.dev" target="_blank" rel="noopener noreferrer" className="text-link hover:underline">nexus.gc-si.dev</a></td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">패키지 저장소</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">가이드</td>
|
||||||
|
<td className="px-4 py-3"><a href="https://guide.gc-si.dev" target="_blank" rel="noopener noreferrer" className="text-link hover:underline">guide.gc-si.dev</a></td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">개발자 가이드 (본 사이트)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" title="접속 안내">
|
||||||
|
모든 서비스는 <strong>Google OAuth (@gcsc.co.kr)</strong>로 인증합니다. 사내 Google Workspace 계정이 필요합니다.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/content/GitWorkflow.tsx
Normal file
190
src/content/GitWorkflow.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function GitWorkflow() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Git 워크플로우</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
팀 브랜치 전략, 커밋 규칙, 3계층 보호 정책을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 브랜치 전략 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">브랜치 전략</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-6">
|
||||||
|
<div className="font-mono text-sm space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="inline-block w-24 px-2 py-1 bg-danger/10 text-danger rounded text-center font-semibold">main</span>
|
||||||
|
<span className="text-text-secondary">← 배포 가능한 안정 브랜치 (보호됨)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-6">
|
||||||
|
<span className="text-text-muted">└──</span>
|
||||||
|
<span className="inline-block w-24 px-2 py-1 bg-info/10 text-info rounded text-center font-semibold">develop</span>
|
||||||
|
<span className="text-text-secondary">← 개발 통합 브랜치</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-16">
|
||||||
|
<span className="text-text-muted">├──</span>
|
||||||
|
<span className="text-success">feature/ISSUE-123-기능설명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-16">
|
||||||
|
<span className="text-text-muted">├──</span>
|
||||||
|
<span className="text-warning">bugfix/ISSUE-456-버그설명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-16">
|
||||||
|
<span className="text-text-muted">└──</span>
|
||||||
|
<span className="text-danger">hotfix/ISSUE-789-긴급수정</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="warning">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code> 브랜치에 직접 커밋/푸시는 금지됩니다. 반드시 MR을 통해 머지하세요.{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치는 push 가능하지만 서버 pre-receive hook 검증을 통과해야 합니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 브랜치 네이밍 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">브랜치 네이밍</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">유형</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">패턴</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">예시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">기능</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-text-secondary">feature/ISSUE-번호-설명</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-accent">feature/ISSUE-42-user-login</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">버그</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-text-secondary">bugfix/ISSUE-번호-설명</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-accent">bugfix/ISSUE-56-date-format</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">긴급</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-text-secondary">hotfix/ISSUE-번호-설명</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-accent">hotfix/ISSUE-99-api-timeout</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conventional Commits */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">Conventional Commits</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
모든 커밋 메시지는 다음 형식을 따릅니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`type(scope): subject
|
||||||
|
|
||||||
|
body (선택)
|
||||||
|
|
||||||
|
footer (선택)`}
|
||||||
|
/>
|
||||||
|
<div className="overflow-x-auto mt-4">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">type</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">설명</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">예시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{[
|
||||||
|
{ type: 'feat', desc: '새로운 기능 추가', ex: 'feat(auth): JWT 기반 로그인 구현' },
|
||||||
|
{ type: 'fix', desc: '버그 수정', ex: 'fix(배치): 야간 배치 타임아웃 수정' },
|
||||||
|
{ type: 'refactor', desc: '리팩토링', ex: 'refactor(user): 중복 로직 추출' },
|
||||||
|
{ type: 'docs', desc: '문서 변경', ex: 'docs: README에 빌드 방법 추가' },
|
||||||
|
{ type: 'test', desc: '테스트 추가/수정', ex: 'test(결제): 환불 로직 단위 테스트' },
|
||||||
|
{ type: 'chore', desc: '빌드, 설정 변경', ex: 'chore: Gradle 의존성 업데이트' },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.type}>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent font-medium">{row.type}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{row.desc}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">{row.ex}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 흐름 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">일반 작업 흐름</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: '이슈 확인 및 브랜치 생성',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b feature/ISSUE-42-user-login`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '개발 및 커밋',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# 작업 후 커밋
|
||||||
|
git add src/auth/LoginForm.tsx
|
||||||
|
git commit -m "feat(auth): 로그인 폼 UI 구현 (#42)"`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '푸시 및 MR 생성',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git push -u origin feature/ISSUE-42-user-login
|
||||||
|
# Gitea에서 MR 생성 (develop ← feature/ISSUE-42-user-login)`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '리뷰 후 머지',
|
||||||
|
content: (
|
||||||
|
<p>최소 1명의 리뷰어 승인 → <strong>Squash Merge</strong> → 소스 브랜치 삭제</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3계층 보호 정책 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">3계층 보호 정책</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-danger mb-1">1. Git Hooks (로컬)</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">commit-msg</code> 훅으로 커밋 메시지 형식을 검증합니다. Conventional Commits 규칙에 맞지 않으면 커밋이 거부됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-warning mb-1">2. 서버 Pre-receive Hook</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Push 시 서버에서 <code className="bg-bg-tertiary px-1 rounded">pre-receive hook</code>이 자동 실행됩니다.{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code>/<code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치 직접 push 차단,
|
||||||
|
커밋 메시지 형식 검증, 금지 파일(credentials, .env 등) 차단, force push 차단을 수행합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-success mb-1">3. 브랜치 보호 규칙</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code> 브랜치는 MR을 통해서만 머지 가능하며, 최소 1명의 리뷰어 승인이 필수입니다.{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치는 push가 허용되지만, pre-receive hook 검증을 통과해야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/content/GiteaUsage.tsx
Normal file
132
src/content/GiteaUsage.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function GiteaUsage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Gitea 사용법</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
팀 Git 저장소인 Gitea의 로그인, 리포지토리 관리, 이슈/MR 사용법을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 로그인 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">로그인</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'Gitea 접속',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
브라우저에서 <strong>gitea.gc-si.dev</strong>에 접속합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Google OAuth 로그인',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
<strong>"Sign in with OpenID Connect"</strong> 버튼을 클릭하여 @gcsc.co.kr Google 계정으로 로그인합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '최초 로그인 시 조직 확인',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
최초 로그인 시 <strong>gc</strong> 조직에 자동으로 소속됩니다. 대시보드에서 조직 리포지토리 목록을 확인하세요.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 리포지토리 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">리포지토리 클론</h2>
|
||||||
|
<p className="text-text-secondary mb-4">SSH 또는 HTTPS로 리포지토리를 클론할 수 있습니다.</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# SSH (권장 — SSH 키 등록 필요)
|
||||||
|
git clone git@gitea.gc-si.dev:gc/프로젝트명.git
|
||||||
|
|
||||||
|
# HTTPS
|
||||||
|
git clone https://gitea.gc-si.dev/gc/프로젝트명.git`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
SSH 키가 등록되어 있으면 별도 인증 없이 push/pull이 가능합니다. 초기 환경 설정 가이드에서 SSH 키를 먼저 등록하세요.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 이슈 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">이슈 관리</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
기능 요청, 버그 리포트, 작업 단위를 이슈로 관리합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: '이슈 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
리포지토리 → <strong>이슈</strong> → <strong>새 이슈</strong>를 클릭하고, 제목과 설명을 작성합니다. 라벨(<code className="bg-bg-tertiary px-1 rounded">feature</code>, <code className="bg-bg-tertiary px-1 rounded">bug</code> 등)을 지정하세요.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '브랜치와 연결',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">이슈 번호를 포함한 브랜치를 생성합니다.</p>
|
||||||
|
<CodeBlock language="bash" code="git checkout -b feature/ISSUE-42-user-login develop" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '커밋에 이슈 참조',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">커밋 메시지에 이슈 번호를 포함하면 자동으로 연결됩니다.</p>
|
||||||
|
<CodeBlock language="bash" code='git commit -m "feat(auth): 로그인 폼 구현 (#42)"' />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MR */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">MR (Merge Request)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
코드 리뷰와 머지를 위해 MR을 생성합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: '브랜치 푸시',
|
||||||
|
content: (
|
||||||
|
<CodeBlock language="bash" code="git push -u origin feature/ISSUE-42-user-login" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'MR 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Gitea에서 <strong>"새 Pull Request"</strong>를 클릭하고, <code className="bg-bg-tertiary px-1 rounded">develop</code> ← <code className="bg-bg-tertiary px-1 rounded">feature/ISSUE-42-user-login</code>으로 설정합니다. 제목은 커밋 규칙과 동일하게 작성하세요.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '리뷰 및 머지',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
리뷰어를 지정하고, 최소 1명의 승인 후 <strong>Squash Merge</strong>로 머지합니다. 머지 후 소스 브랜치는 삭제합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert type="warning" title="주의">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code>, <code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치에는 직접 push가 금지되어 있습니다. 반드시 MR을 통해 머지하세요.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/content/InitialSetup.tsx
Normal file
166
src/content/InitialSetup.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function InitialSetup() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">초기 환경 설정</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
개발을 시작하기 전 필요한 도구와 설정을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* SSH 키 생성 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">1. SSH 키 생성 및 등록</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea에 SSH로 접근하려면 SSH 키가 필요합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'SSH 키 생성',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">터미널에서 다음 명령어를 실행합니다.</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`ssh-keygen -t ed25519 -C "your-email@gcsc.co.kr"
|
||||||
|
# Enter 키를 눌러 기본 경로에 저장
|
||||||
|
# 패스프레이즈는 선택사항`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '공개 키 복사',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<CodeBlock language="bash" code="cat ~/.ssh/id_ed25519.pub" />
|
||||||
|
<p className="mt-2">출력된 내용을 전체 복사합니다.</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gitea에 등록',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Gitea → <strong>설정</strong> → <strong>SSH / GPG 키</strong> → <strong>SSH 키 추가</strong>에서 복사한 공개 키를 붙여넣고 저장합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Git 설정 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">2. Git 기본 설정</h2>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
filename="~/.gitconfig"
|
||||||
|
code={`git config --global user.name "홍길동"
|
||||||
|
git config --global user.email "hong@gcsc.co.kr"
|
||||||
|
git config --global init.defaultBranch main
|
||||||
|
git config --global core.autocrlf input`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
이메일은 반드시 <strong>@gcsc.co.kr</strong> 도메인을 사용하세요. Gitea 커밋 연동에 필요합니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* SDKMAN */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">3. SDKMAN! (JDK 관리)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Java 프로젝트를 위해 SDKMAN!으로 JDK를 관리합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'SDKMAN! 설치',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`curl -s "https://get.sdkman.io" | bash
|
||||||
|
source "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||||
|
sdk version`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'JDK 설치',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# Amazon Corretto 21 (기본)
|
||||||
|
sdk install java 21.0.9-amzn
|
||||||
|
|
||||||
|
# 프로젝트별 JDK 17이 필요한 경우
|
||||||
|
sdk install java 17.0.18-amzn
|
||||||
|
sdk use java 17.0.18-amzn`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
프로젝트 루트에 <code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code> 파일이 있으면 해당 버전이 자동 선택됩니다.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Maven 설치',
|
||||||
|
content: (
|
||||||
|
<CodeBlock language="bash" code="sdk install maven 3.9.12" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* fnm */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">4. fnm (Node.js 관리)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
프론트엔드 프로젝트를 위해 fnm으로 Node.js를 관리합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'fnm 설치',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`curl -fsSL https://fnm.vercel.app/install | bash
|
||||||
|
# 셸 재시작 후
|
||||||
|
fnm --version`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Node.js 설치',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`fnm install 24
|
||||||
|
fnm default 24
|
||||||
|
node --version`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
프로젝트 루트에 <code className="bg-bg-tertiary px-1 rounded">.node-version</code> 파일이 있으면 <code className="bg-bg-tertiary px-1 rounded">fnm use</code>로 자동 전환됩니다.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Claude Code */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">5. Claude Code 설치</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
AI 기반 코딩 어시스턴트로 개발 생산성을 높입니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`npm install -g @anthropic-ai/claude-code
|
||||||
|
claude --version`}
|
||||||
|
/>
|
||||||
|
<Alert type="warning" title="API 키 필요">
|
||||||
|
Claude Code 사용을 위해 팀 관리자에게 API 키를 요청하세요.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/content/NexusUsage.tsx
Normal file
116
src/content/NexusUsage.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
|
||||||
|
export default function NexusUsage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Nexus 사용법</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
Maven, Gradle, npm 프록시 설정 방법과 프라이빗 패키지 배포 가이드입니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Alert type="info" title="Nexus 주소">
|
||||||
|
<strong>nexus.gc-si.dev</strong> — 웹 UI에서 저장소 목록과 패키지를 확인할 수 있습니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Maven */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">Maven 프록시 설정</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Maven 프로젝트에서 Nexus를 프록시로 사용하려면 <code className="bg-bg-tertiary px-1 rounded">~/.m2/settings.xml</code>을 설정합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="xml"
|
||||||
|
filename="~/.m2/settings.xml"
|
||||||
|
code={`<settings>
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>nexus</id>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>nexus</id>
|
||||||
|
<username>\${env.NEXUS_USERNAME}</username>
|
||||||
|
<password>\${env.NEXUS_PASSWORD}</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
</settings>`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gradle */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">Gradle 프록시 설정</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">build.gradle</code>의 repositories 블록에 Nexus를 추가합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="groovy"
|
||||||
|
filename="build.gradle"
|
||||||
|
code={`repositories {
|
||||||
|
maven {
|
||||||
|
url 'https://nexus.gc-si.dev/repository/maven-public/'
|
||||||
|
credentials {
|
||||||
|
username = findProperty('nexusUsername') ?: System.getenv('NEXUS_USERNAME')
|
||||||
|
password = findProperty('nexusPassword') ?: System.getenv('NEXUS_PASSWORD')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
인증 정보는 <code className="bg-bg-tertiary px-1 rounded">~/.gradle/gradle.properties</code>에 <code className="bg-bg-tertiary px-1 rounded">nexusUsername</code>/<code className="bg-bg-tertiary px-1 rounded">nexusPassword</code>로 설정할 수도 있습니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* npm */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">npm 프록시 설정</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
프로젝트 루트의 <code className="bg-bg-tertiary px-1 rounded">.npmrc</code> 파일에 Nexus 레지스트리를 설정합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="ini"
|
||||||
|
filename=".npmrc"
|
||||||
|
code={`registry=https://nexus.gc-si.dev/repository/npm-public/
|
||||||
|
//nexus.gc-si.dev/repository/npm-public/:_auth=\${NPM_AUTH_TOKEN}
|
||||||
|
always-auth=true`}
|
||||||
|
/>
|
||||||
|
<Alert type="warning" title="보안 주의">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">_auth</code> 값을 <code className="bg-bg-tertiary px-1 rounded">.npmrc</code>에 직접 하드코딩하지 마세요. 환경변수 또는 <code className="bg-bg-tertiary px-1 rounded">~/.npmrc</code>(글로벌)에 설정하고, 프로젝트 <code className="bg-bg-tertiary px-1 rounded">.npmrc</code>는 레지스트리 URL만 포함합니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 패키지 배포 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">프라이빗 패키지 배포</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
사내 공유 라이브러리를 Nexus에 배포할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">Maven 배포</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="xml"
|
||||||
|
filename="pom.xml"
|
||||||
|
code={`<distributionManagement>
|
||||||
|
<repository>
|
||||||
|
<id>nexus</id>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-releases/</url>
|
||||||
|
</repository>
|
||||||
|
<snapshotRepository>
|
||||||
|
<id>nexus</id>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-snapshots/</url>
|
||||||
|
</snapshotRepository>
|
||||||
|
</distributionManagement>`}
|
||||||
|
/>
|
||||||
|
<CodeBlock language="bash" code="mvn deploy" />
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">npm 배포</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
filename="package.json"
|
||||||
|
code={`{
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://nexus.gc-si.dev/repository/npm-hosted/"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<CodeBlock language="bash" code="npm publish" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
src/content/StartingProject.tsx
Normal file
227
src/content/StartingProject.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function StartingProject() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">프로젝트 시작하기</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
팀 템플릿을 사용해 새 프로젝트를 빠르게 시작하는 방법을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 템플릿 비교 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">프로젝트 템플릿</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea <code className="bg-bg-tertiary px-1 rounded">gc</code> 조직에서 프로젝트 유형에 맞는 템플릿을 선택합니다.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">템플릿</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">기술 스택</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">포함 내용</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-java-maven</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">Java + Spring Boot + Maven</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.mvn/settings.xml</code>,{' '}
|
||||||
|
Claude 규칙/스킬, Git hooks
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-java-gradle</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">Java + Spring Boot + Gradle</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">gradle.properties.example</code>,{' '}
|
||||||
|
Claude 규칙/스킬, Git hooks
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-react-ts</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">React + TypeScript + Vite</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.node-version</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.npmrc</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.prettierrc</code>,{' '}
|
||||||
|
Claude 규칙/스킬, Git hooks
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-common</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">공통 워크플로우</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
팀 규칙, Claude 스킬, Git hooks, 버전 관리 (프로젝트 템플릿이 아닌 규칙 원본)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info">
|
||||||
|
모든 프로젝트 템플릿에는 <code className="bg-bg-tertiary px-1 rounded">.claude/</code> 디렉토리(규칙, 스킬, 설정),{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.githooks/</code>(commit-msg, post-checkout),{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.editorconfig</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">CLAUDE.md</code>가 공통으로 포함되어 있습니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 새 프로젝트 생성 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">새 프로젝트 생성</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'Gitea에서 리포지토리 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Gitea → <strong>gc</strong> 조직 → <strong>새 저장소</strong>를 클릭합니다.
|
||||||
|
<strong> "템플릿에서 생성"</strong>에서 프로젝트 유형에 맞는 템플릿(<code className="bg-bg-tertiary px-1 rounded">template-java-maven</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">template-java-gradle</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">template-react-ts</code>)을 선택하고 프로젝트 이름을 입력합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '로컬에 클론',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git clone git@gitea.gc-si.dev:gc/새-프로젝트명.git
|
||||||
|
cd 새-프로젝트명`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Claude Code로 초기화',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">Claude Code 세션에서 프로젝트 초기화 스킬을 실행합니다.</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`claude
|
||||||
|
# 세션 내에서 실행:
|
||||||
|
/init-project`}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-text-muted text-xs">
|
||||||
|
이 명령은 팀 워크플로우 규칙, Git hooks, Claude 설정 파일을 자동으로 구성합니다.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'develop 브랜치 생성',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git checkout -b develop
|
||||||
|
git push -u origin develop`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '첫 feature 브랜치 시작',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git checkout -b feature/ISSUE-1-project-setup
|
||||||
|
# 코딩 시작!`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 템플릿 공통 파일 구조 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">템플릿 공통 파일 구조</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
모든 프로젝트 템플릿에 포함되는 공통 파일입니다.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="font-mono text-sm space-y-1.5 text-text-secondary">
|
||||||
|
<div><span className="text-accent">.claude/</span></div>
|
||||||
|
<div className="pl-4"><span className="text-accent">rules/</span> — 팀 규칙 (code-style, git-workflow, naming, testing, team-policy)</div>
|
||||||
|
<div className="pl-4"><span className="text-accent">skills/</span> — Claude 스킬 (create-mr, fix-issue, init-project, sync-team-workflow)</div>
|
||||||
|
<div className="pl-4"><span className="text-accent">settings.json</span> — Claude 권한 설정</div>
|
||||||
|
<div className="mt-2"><span className="text-accent">.githooks/</span></div>
|
||||||
|
<div className="pl-4"><span className="text-accent">commit-msg</span> — Conventional Commits 검증 훅</div>
|
||||||
|
<div className="pl-4"><span className="text-accent">post-checkout</span> — 체크아웃 후 자동 실행</div>
|
||||||
|
<div className="mt-2"><span className="text-accent">.editorconfig</span> — 에디터 공통 설정</div>
|
||||||
|
<div><span className="text-accent">.gitignore</span> — Git 제외 패턴</div>
|
||||||
|
<div><span className="text-accent">CLAUDE.md</span> — 프로젝트 설명서</div>
|
||||||
|
<div><span className="text-accent">workflow-version.json</span> — 워크플로우 버전 추적</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 템플릿별 추가 파일 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">템플릿별 추가 파일</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-2">template-java-maven</h4>
|
||||||
|
<div className="font-mono text-xs space-y-1 text-text-secondary">
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code> — JDK 버전</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.mvn/settings.xml</code> — Maven 설정</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-2">template-java-gradle</h4>
|
||||||
|
<div className="font-mono text-xs space-y-1 text-text-secondary">
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code> — JDK 버전</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">gradle.properties.example</code> — Gradle 설정 예시</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-2">template-react-ts</h4>
|
||||||
|
<div className="font-mono text-xs space-y-1 text-text-secondary">
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.node-version</code> — Node.js 버전</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.npmrc</code> — npm 레지스트리</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.prettierrc</code> — 코드 포매터</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" title="워크플로우 업데이트">
|
||||||
|
팀 워크플로우가 업데이트되면 세션 시작 시 알림이 표시됩니다.
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded ml-1">/sync-team-workflow</code>를 실행하여 최신 규칙을 동기화하세요.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 프로젝트 구조 권장안 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">권장 프로젝트 구조</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-text-primary mb-2">Spring Boot (Maven/Gradle)</h4>
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`src/main/java/com/gcsc/프로젝트/
|
||||||
|
├── controller/
|
||||||
|
├── service/
|
||||||
|
├── repository/
|
||||||
|
├── dto/
|
||||||
|
├── entity/
|
||||||
|
├── config/
|
||||||
|
└── common/`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-text-primary mb-2">React + TypeScript</h4>
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`src/
|
||||||
|
├── components/
|
||||||
|
│ ├── common/
|
||||||
|
│ └── layout/
|
||||||
|
├── pages/
|
||||||
|
├── hooks/
|
||||||
|
├── services/
|
||||||
|
├── types/
|
||||||
|
└── utils/`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/hooks/ThemeContext.ts
Normal file
15
src/hooks/ThemeContext.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
theme: 'system',
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
setTheme: () => {},
|
||||||
|
});
|
||||||
62
src/hooks/ThemeProvider.tsx
Normal file
62
src/hooks/ThemeProvider.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { ThemeContext } from './ThemeContext';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'gc-guide-theme';
|
||||||
|
|
||||||
|
function getSystemTheme(): 'light' | 'dark' {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(
|
||||||
|
getSystemTheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
mediaQuery.addEventListener('change', handler);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTheme = useCallback((newTheme: Theme) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||||
|
setThemeState(newTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ theme, resolvedTheme, setTheme }),
|
||||||
|
[theme, resolvedTheme, setTheme],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/hooks/useScrollSpy.ts
Normal file
32
src/hooks/useScrollSpy.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useScrollSpy(selectors: string = 'h2, h3') {
|
||||||
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const elements = document.querySelectorAll(selectors);
|
||||||
|
if (elements.length === 0) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting && entry.target.id) {
|
||||||
|
setActiveId(entry.target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '-80px 0px -60% 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.forEach((el) => {
|
||||||
|
if (el.id) observer.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [selectors]);
|
||||||
|
|
||||||
|
return activeId;
|
||||||
|
}
|
||||||
6
src/hooks/useTheme.ts
Normal file
6
src/hooks/useTheme.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext } from './ThemeContext';
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
105
src/index.css
105
src/index.css
@ -1 +1,106 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "highlight.js/styles/atom-one-dark.css";
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
시맨틱 색상 시스템 (Tailwind CSS v4 @theme)
|
||||||
|
- Light: react-mda-frontend 참조
|
||||||
|
- Dark: dark 프로젝트 참조
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-bg-primary: var(--theme-bg-primary);
|
||||||
|
--color-bg-secondary: var(--theme-bg-secondary);
|
||||||
|
--color-bg-tertiary: var(--theme-bg-tertiary);
|
||||||
|
--color-text-primary: var(--theme-text-primary);
|
||||||
|
--color-text-secondary: var(--theme-text-secondary);
|
||||||
|
--color-text-muted: var(--theme-text-muted);
|
||||||
|
--color-border-default: var(--theme-border-default);
|
||||||
|
--color-border-subtle: var(--theme-border-subtle);
|
||||||
|
--color-accent: var(--theme-accent);
|
||||||
|
--color-accent-hover: var(--theme-accent-hover);
|
||||||
|
--color-accent-soft: var(--theme-accent-soft);
|
||||||
|
--color-surface: var(--theme-surface);
|
||||||
|
--color-sidebar-bg: var(--theme-sidebar-bg);
|
||||||
|
--color-sidebar-active-bg: var(--theme-sidebar-active-bg);
|
||||||
|
--color-sidebar-active-text: var(--theme-sidebar-active-text);
|
||||||
|
--color-sidebar-text: var(--theme-sidebar-text);
|
||||||
|
--color-info: var(--theme-info);
|
||||||
|
--color-success: var(--theme-success);
|
||||||
|
--color-warning: var(--theme-warning);
|
||||||
|
--color-danger: var(--theme-danger);
|
||||||
|
--color-link: var(--theme-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light 테마 (기본) */
|
||||||
|
:root,
|
||||||
|
[data-theme="light"] {
|
||||||
|
--theme-bg-primary: #f8f9fa;
|
||||||
|
--theme-bg-secondary: #ffffff;
|
||||||
|
--theme-bg-tertiary: #e9ecef;
|
||||||
|
--theme-text-primary: #040404;
|
||||||
|
--theme-text-secondary: #666666;
|
||||||
|
--theme-text-muted: #999999;
|
||||||
|
--theme-border-default: #D6DBE3;
|
||||||
|
--theme-border-subtle: #dddddd;
|
||||||
|
--theme-accent: #213079;
|
||||||
|
--theme-accent-hover: #1D329B;
|
||||||
|
--theme-accent-soft: #eaf2fd;
|
||||||
|
--theme-surface: #ffffff;
|
||||||
|
--theme-sidebar-bg: #262B44;
|
||||||
|
--theme-sidebar-active-bg: #0C30B6;
|
||||||
|
--theme-sidebar-active-text: #A3B2FF;
|
||||||
|
--theme-sidebar-text: #A3B2FF;
|
||||||
|
--theme-info: #2494d3;
|
||||||
|
--theme-success: #198754;
|
||||||
|
--theme-warning: #ffc107;
|
||||||
|
--theme-danger: #dc3545;
|
||||||
|
--theme-link: #426891;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark 테마 */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--theme-bg-primary: #011C2F;
|
||||||
|
--theme-bg-secondary: #122D41;
|
||||||
|
--theme-bg-tertiary: #2C3D4D;
|
||||||
|
--theme-text-primary: #FFFFFF;
|
||||||
|
--theme-text-secondary: #AAAAAA;
|
||||||
|
--theme-text-muted: #999999;
|
||||||
|
--theme-border-default: #0A3F4E;
|
||||||
|
--theme-border-subtle: rgba(255, 255, 255, 0.1);
|
||||||
|
--theme-accent: #02908B;
|
||||||
|
--theme-accent-hover: #04C2C3;
|
||||||
|
--theme-accent-soft: rgba(2, 144, 139, 0.15);
|
||||||
|
--theme-surface: #122D41;
|
||||||
|
--theme-sidebar-bg: #011C2F;
|
||||||
|
--theme-sidebar-active-bg: #02908B;
|
||||||
|
--theme-sidebar-active-text: #FFFFFF;
|
||||||
|
--theme-sidebar-text: #AAAAAA;
|
||||||
|
--theme-info: #2494d3;
|
||||||
|
--theme-success: #51C2AC;
|
||||||
|
--theme-warning: #FF8B36;
|
||||||
|
--theme-danger: #FF0000;
|
||||||
|
--theme-link: #04C2C3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 인라인 코드 (다크 테마 대응) */
|
||||||
|
code:not(pre code) {
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤바 (다크 테마 대응) */
|
||||||
|
[data-theme="dark"] ::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] ::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|||||||
@ -8,22 +8,22 @@ export function DeniedPage() {
|
|||||||
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-bg-primary flex items-center justify-center px-4">
|
||||||
<div className="bg-white rounded-2xl shadow-lg p-10 max-w-md w-full text-center">
|
<div className="bg-surface rounded-2xl shadow-lg p-10 max-w-md w-full text-center">
|
||||||
<div className="w-16 h-16 bg-red-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
<div className="w-16 h-16 bg-red-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 mb-2">접근이 거부되었습니다</h1>
|
<h1 className="text-xl font-bold text-text-primary mb-2">접근이 거부되었습니다</h1>
|
||||||
<p className="text-gray-500 text-sm mb-6">
|
<p className="text-text-muted text-sm mb-6">
|
||||||
계정이 {user.status === 'REJECTED' ? '거절' : '비활성화'}되었습니다.
|
계정이 {user.status === 'REJECTED' ? '거절' : '비활성화'}되었습니다.
|
||||||
<br />
|
<br />
|
||||||
관리자에게 문의하세요.
|
관리자에게 문의하세요.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer"
|
className="text-sm text-link hover:text-accent-hover cursor-pointer"
|
||||||
>
|
>
|
||||||
다른 계정으로 로그인
|
다른 계정으로 로그인
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,27 +1,39 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
const GUIDE_TITLES: Record<string, string> = {
|
const CONTENT_MAP: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
|
||||||
'env-intro': '개발환경 소개',
|
'env-intro': lazy(() => import('../content/DevEnvIntro')),
|
||||||
'initial-setup': '초기 환경 설정',
|
'initial-setup': lazy(() => import('../content/InitialSetup')),
|
||||||
'gitea-usage': 'Gitea 사용법',
|
'gitea-usage': lazy(() => import('../content/GiteaUsage')),
|
||||||
'nexus-usage': 'Nexus 사용법',
|
'nexus-usage': lazy(() => import('../content/NexusUsage')),
|
||||||
'git-workflow': 'Git 워크플로우',
|
'git-workflow': lazy(() => import('../content/GitWorkflow')),
|
||||||
'chat-bot': 'Chat 봇 연동',
|
'chat-bot': lazy(() => import('../content/ChatBotIntegration')),
|
||||||
'starting-project': '프로젝트 시작하기',
|
'starting-project': lazy(() => import('../content/StartingProject')),
|
||||||
|
'design-system': lazy(() => import('../content/DesignSystem')),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GuidePage() {
|
export function GuidePage() {
|
||||||
const { section } = useParams<{ section: string }>();
|
const { section } = useParams<{ section: string }>();
|
||||||
const title = section ? GUIDE_TITLES[section] : '가이드';
|
const Content = section ? CONTENT_MAP[section] : null;
|
||||||
|
|
||||||
|
if (!Content) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-4">페이지를 찾을 수 없습니다</h1>
|
||||||
|
<p className="text-text-secondary">요청한 가이드 섹션이 존재하지 않습니다.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-12 px-6">
|
<Suspense
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
fallback={
|
||||||
{title || '가이드'}
|
<div className="flex items-center justify-center py-20">
|
||||||
</h1>
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-blue-800 text-sm">
|
</div>
|
||||||
이 섹션의 콘텐츠는 준비 중입니다.
|
}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Content />
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
@ -5,10 +6,10 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-12 px-6">
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
<h1 className="text-3xl font-bold text-text-primary mb-4">
|
||||||
GC SI 개발자 가이드
|
GC SI 개발자 가이드
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 mb-8">
|
<p className="text-text-secondary mb-8">
|
||||||
환영합니다{user ? `, ${user.name}님` : ''}! 팀 개발 환경 설정 및
|
환영합니다{user ? `, ${user.name}님` : ''}! 팀 개발 환경 설정 및
|
||||||
워크플로우 가이드입니다.
|
워크플로우 가이드입니다.
|
||||||
</p>
|
</p>
|
||||||
@ -20,15 +21,17 @@ export function HomePage() {
|
|||||||
{ title: 'Nexus 사용법', desc: '패키지 프록시 설정', path: '/dev/nexus-usage' },
|
{ title: 'Nexus 사용법', desc: '패키지 프록시 설정', path: '/dev/nexus-usage' },
|
||||||
{ title: 'Git 워크플로우', desc: '브랜치 전략 및 코드 리뷰', path: '/dev/git-workflow' },
|
{ title: 'Git 워크플로우', desc: '브랜치 전략 및 코드 리뷰', path: '/dev/git-workflow' },
|
||||||
{ title: 'Chat 봇 연동', desc: '알림 및 봇 명령어', path: '/dev/chat-bot' },
|
{ title: 'Chat 봇 연동', desc: '알림 및 봇 명령어', path: '/dev/chat-bot' },
|
||||||
|
{ title: '프로젝트 시작하기', desc: '템플릿 기반 새 프로젝트', path: '/dev/starting-project' },
|
||||||
|
{ title: '디자인 시스템', desc: 'UI 컴포넌트 쇼케이스 및 테마', path: '/dev/design-system' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<a
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
href={item.path}
|
to={item.path}
|
||||||
className="block p-5 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition"
|
className="block p-5 bg-surface rounded-xl border border-border-default hover:border-accent hover:shadow-md transition"
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold text-gray-900">{item.title}</h3>
|
<h3 className="font-semibold text-text-primary">{item.title}</h3>
|
||||||
<p className="text-sm text-gray-500 mt-1">{item.desc}</p>
|
<p className="text-sm text-text-muted mt-1">{item.desc}</p>
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
@ -6,42 +7,67 @@ const GOOGLE_CLIENT_ID =
|
|||||||
'295080817934-1uqaqrkup9jnslajkl1ngpee7gm249fv.apps.googleusercontent.com';
|
'295080817934-1uqaqrkup9jnslajkl1ngpee7gm249fv.apps.googleusercontent.com';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { user, login, loading } = useAuth();
|
const { user, login, devLogin, loading } = useAuth();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
if (user && user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
if (user && user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
||||||
if (user && user.status === 'PENDING')
|
if (user && user.status === 'PENDING')
|
||||||
return <Navigate to="/pending" replace />;
|
return <Navigate to="/pending" replace />;
|
||||||
|
|
||||||
|
const handleLogin = async (credential: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(credential);
|
||||||
|
} catch (e) {
|
||||||
|
setError(
|
||||||
|
e instanceof Error && e.message
|
||||||
|
? e.message
|
||||||
|
: '로그인에 실패했습니다. 다시 시도해주세요.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 flex items-center justify-center px-4">
|
||||||
<div className="bg-white rounded-2xl shadow-2xl p-10 max-w-sm w-full text-center">
|
<div className="bg-surface rounded-2xl shadow-2xl p-10 max-w-sm w-full text-center">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="w-16 h-16 bg-blue-600 rounded-xl mx-auto mb-4 flex items-center justify-center">
|
<div className="w-16 h-16 bg-accent rounded-xl mx-auto mb-4 flex items-center justify-center">
|
||||||
<span className="text-white text-2xl font-bold">GC</span>
|
<span className="text-white text-2xl font-bold">GC</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-text-primary">
|
||||||
GC SI 개발자 가이드
|
GC SI 개발자 가이드
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 mt-2 text-sm">
|
<p className="text-text-muted mt-2 text-sm">
|
||||||
@gcsc.co.kr 계정으로 로그인하세요
|
@gcsc.co.kr 계정으로 로그인하세요
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-2.5 bg-danger/10 border border-danger/30 rounded-lg text-sm text-danger">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<GoogleLogin
|
<GoogleLogin
|
||||||
onSuccess={(res) => {
|
onSuccess={(res) => {
|
||||||
if (res.credential) login(res.credential);
|
if (res.credential) handleLogin(res.credential);
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
console.error('Google Login failed');
|
|
||||||
}}
|
}}
|
||||||
|
onError={() => setError('Google 인증에 실패했습니다.')}
|
||||||
theme="outline"
|
theme="outline"
|
||||||
size="large"
|
size="large"
|
||||||
width="280"
|
width="280"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-6">
|
{devLogin && (
|
||||||
|
<button
|
||||||
|
onClick={devLogin}
|
||||||
|
className="mt-4 w-full px-4 py-2 bg-amber-500/10 text-amber-600 border border-amber-500/30 rounded-lg text-sm font-medium hover:bg-amber-500/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
개발 모드 로그인 (Mock)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-muted mt-6">
|
||||||
GC SI 사내 개발환경 전용
|
GC SI 사내 개발환경 전용
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,25 +8,25 @@ export function PendingPage() {
|
|||||||
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-bg-primary flex items-center justify-center px-4">
|
||||||
<div className="bg-white rounded-2xl shadow-lg p-10 max-w-md w-full text-center">
|
<div className="bg-surface rounded-2xl shadow-lg p-10 max-w-md w-full text-center">
|
||||||
<div className="w-16 h-16 bg-amber-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
<div className="w-16 h-16 bg-amber-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||||
<svg className="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 mb-2">승인 대기 중</h1>
|
<h1 className="text-xl font-bold text-text-primary mb-2">승인 대기 중</h1>
|
||||||
<p className="text-gray-600 mb-1">
|
<p className="text-text-secondary mb-1">
|
||||||
<span className="font-medium">{user.email}</span>
|
<span className="font-medium">{user.email}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-sm mb-6">
|
<p className="text-text-muted text-sm mb-6">
|
||||||
관리자의 승인 후 가이드에 접근할 수 있습니다.
|
관리자의 승인 후 가이드에 접근할 수 있습니다.
|
||||||
<br />
|
<br />
|
||||||
승인이 완료되면 다시 로그인해주세요.
|
승인이 완료되면 다시 로그인해주세요.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer"
|
className="text-sm text-link hover:text-accent-hover cursor-pointer"
|
||||||
>
|
>
|
||||||
다른 계정으로 로그인
|
다른 계정으로 로그인
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
171
src/pages/admin/PermissionManagement.tsx
Normal file
171
src/pages/admin/PermissionManagement.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { Permission, Role } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
export function PermissionManagement() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
||||||
|
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||||
|
const [newPattern, setNewPattern] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchRoles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<Role[]>('/admin/roles');
|
||||||
|
setRoles(data);
|
||||||
|
if (data.length > 0 && !selectedRoleId) {
|
||||||
|
setSelectedRoleId(data[0].id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 빈 배열 유지
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedRoleId]);
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
if (!selectedRoleId) return;
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ urlPatterns: Permission[] }>(`/admin/roles/${selectedRoleId}/permissions`);
|
||||||
|
setPermissions(Array.isArray(data.urlPatterns) ? data.urlPatterns : []);
|
||||||
|
} catch {
|
||||||
|
setPermissions([]);
|
||||||
|
}
|
||||||
|
}, [selectedRoleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
}, [fetchRoles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selectedRoleId || !newPattern.trim()) return;
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/roles/${selectedRoleId}/permissions`, { urlPattern: newPattern.trim() });
|
||||||
|
setNewPattern('');
|
||||||
|
fetchPermissions();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (permissionId: number) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/permissions/${permissionId}`);
|
||||||
|
fetchPermissions();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-6">권한 관리</h1>
|
||||||
|
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-text-muted">
|
||||||
|
먼저 롤을 생성하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* 롤 목록 */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">롤 선택</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role.id}
|
||||||
|
onClick={() => setSelectedRoleId(role.id)}
|
||||||
|
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||||
|
selectedRoleId === role.id
|
||||||
|
? 'bg-accent text-white font-medium'
|
||||||
|
: 'text-text-secondary hover:bg-bg-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL 패턴 관리 */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-4">
|
||||||
|
{roles.find((r) => r.id === selectedRoleId)?.name} — URL 패턴
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 추가 폼 */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPattern}
|
||||||
|
onChange={(e) => setNewPattern(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
|
className="flex-1 px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
placeholder="/dev/** 또는 /admin/users"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newPattern.trim()}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 패턴 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{permissions.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted py-4 text-center">
|
||||||
|
등록된 URL 패턴이 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
permissions.map((perm) => (
|
||||||
|
<div
|
||||||
|
key={perm.id}
|
||||||
|
className="flex items-center justify-between px-3 py-2 bg-bg-primary rounded-lg"
|
||||||
|
>
|
||||||
|
<code className="text-sm font-mono text-text-primary">{perm.urlPattern}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(perm.id)}
|
||||||
|
className="text-text-muted hover:text-danger cursor-pointer p-1"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ant 패턴 가이드 */}
|
||||||
|
<div className="mt-6 p-4 bg-bg-tertiary rounded-lg">
|
||||||
|
<h4 className="text-sm font-semibold text-text-primary mb-2">패턴 문법</h4>
|
||||||
|
<div className="text-xs text-text-secondary space-y-1">
|
||||||
|
<p><code className="font-mono text-accent">/**</code> — 모든 경로</p>
|
||||||
|
<p><code className="font-mono text-accent">/dev/**</code> — /dev/ 하위 모든 경로</p>
|
||||||
|
<p><code className="font-mono text-accent">/admin/users</code> — 정확히 일치</p>
|
||||||
|
<p><code className="font-mono text-accent">/api/*/list</code> — 단일 세그먼트 와일드카드</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/pages/admin/RoleManagement.tsx
Normal file
187
src/pages/admin/RoleManagement.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { Role } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
interface RoleForm {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleManagement() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||||
|
const [form, setForm] = useState<RoleForm>({ name: '', description: '' });
|
||||||
|
|
||||||
|
const fetchRoles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<Role[]>('/admin/roles');
|
||||||
|
setRoles(data);
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 빈 배열 유지
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
}, [fetchRoles]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setForm({ name: '', description: '' });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (role: Role) => {
|
||||||
|
setEditingRole(role);
|
||||||
|
setForm({ name: role.name, description: role.description });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
if (editingRole) {
|
||||||
|
await api.put<Role>(`/admin/roles/${editingRole.id}`, form);
|
||||||
|
} else {
|
||||||
|
await api.post<Role>('/admin/roles', form);
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchRoles();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (roleId: number) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/roles/${roleId}`);
|
||||||
|
fetchRoles();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary">롤 관리</h1>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover cursor-pointer"
|
||||||
|
>
|
||||||
|
+ 새 롤
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<div className="col-span-full text-center py-12 text-text-muted">
|
||||||
|
등록된 롤이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
roles.map((role) => (
|
||||||
|
<div key={role.id} className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<h3 className="font-semibold text-text-primary">{role.name}</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(role)}
|
||||||
|
className="p-1.5 text-text-muted hover:text-accent cursor-pointer"
|
||||||
|
title="편집"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(role.id)}
|
||||||
|
className="p-1.5 text-text-muted hover:text-danger cursor-pointer"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-secondary mb-3">{role.description}</p>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-text-muted mb-1">URL 패턴</p>
|
||||||
|
{role.urlPatterns.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{role.urlPatterns.map((p) => (
|
||||||
|
<span key={p} className="px-2 py-0.5 bg-bg-tertiary rounded text-xs font-mono text-text-secondary">
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-text-muted">없음</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 생성/편집 모달 */}
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-surface rounded-xl shadow-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold text-text-primary mb-4">
|
||||||
|
{editingRole ? '롤 편집' : '새 롤 생성'}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">이름</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
placeholder="예: DEVELOPER"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">설명</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent resize-none"
|
||||||
|
rows={3}
|
||||||
|
placeholder="롤에 대한 설명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-bg-tertiary cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name.trim()}
|
||||||
|
className="px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{editingRole ? '수정' : '생성'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/pages/admin/StatsPage.tsx
Normal file
106
src/pages/admin/StatsPage.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { PageStat, StatsResponse } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
export function StatsPage() {
|
||||||
|
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||||
|
const [pageStats] = useState<PageStat[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<StatsResponse>('/admin/stats');
|
||||||
|
setStats(data);
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 기본값
|
||||||
|
setStats({
|
||||||
|
totalUsers: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
pendingUsers: 0,
|
||||||
|
totalPages: 7,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: '전체 사용자', value: stats?.totalUsers ?? 0, color: 'text-accent' },
|
||||||
|
{ label: '활성 사용자', value: stats?.activeUsers ?? 0, color: 'text-success' },
|
||||||
|
{ label: '승인 대기', value: stats?.pendingUsers ?? 0, color: 'text-warning' },
|
||||||
|
{ label: '가이드 페이지', value: stats?.totalPages ?? 0, color: 'text-info' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-6">통계</h1>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<p className="text-sm text-text-muted mb-1">{card.label}</p>
|
||||||
|
<p className={`text-3xl font-bold ${card.color}`}>{card.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인기 페이지 */}
|
||||||
|
<h2 className="text-lg font-bold text-text-primary mb-4">인기 페이지</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">페이지</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">조회수</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">비율</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{pageStats.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-4 py-8 text-center text-text-muted">
|
||||||
|
아직 통계 데이터가 없습니다. 백엔드 API 연동 후 데이터가 표시됩니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
pageStats.map((page) => {
|
||||||
|
const maxViews = Math.max(...pageStats.map((p) => p.viewCount), 1);
|
||||||
|
const percent = Math.round((page.viewCount / maxViews) * 100);
|
||||||
|
return (
|
||||||
|
<tr key={page.pagePath}>
|
||||||
|
<td className="px-4 py-3 font-mono text-text-primary">{page.pagePath}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{page.viewCount}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent rounded-full"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted w-8 text-right">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/pages/admin/UserManagement.tsx
Normal file
248
src/pages/admin/UserManagement.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { Role, User } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
type FilterStatus = 'ALL' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED';
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [filter, setFilter] = useState<FilterStatus>('ALL');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [roleModalUser, setRoleModalUser] = useState<User | null>(null);
|
||||||
|
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [usersData, rolesData] = await Promise.all([
|
||||||
|
api.get<User[]>('/admin/users'),
|
||||||
|
api.get<Role[]>('/admin/roles'),
|
||||||
|
]);
|
||||||
|
setUsers(usersData);
|
||||||
|
setRoles(rolesData);
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 빈 배열 유지
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleAction = async (userId: number, action: 'approve' | 'reject' | 'disable') => {
|
||||||
|
try {
|
||||||
|
await api.put<User>(`/admin/users/${userId}/${action}`, {});
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleSave = async () => {
|
||||||
|
if (!roleModalUser) return;
|
||||||
|
try {
|
||||||
|
await api.put<User>(`/admin/users/${roleModalUser.id}/roles`, { roleIds: selectedRoleIds });
|
||||||
|
setRoleModalUser(null);
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = filter === 'ALL' ? users : users.filter((u) => u.status === filter);
|
||||||
|
|
||||||
|
const statusBadge = (status: User['status']) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
ACTIVE: 'bg-success/10 text-success',
|
||||||
|
PENDING: 'bg-warning/10 text-warning',
|
||||||
|
REJECTED: 'bg-danger/10 text-danger',
|
||||||
|
DISABLED: 'bg-bg-tertiary text-text-muted',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ACTIVE: '활성', PENDING: '대기', REJECTED: '거절', DISABLED: '비활성',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||||
|
{labels[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-6">사용자 관리</h1>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
{(['ALL', 'PENDING', 'ACTIVE', 'REJECTED', 'DISABLED'] as FilterStatus[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilter(s)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm cursor-pointer transition-colors ${
|
||||||
|
filter === s
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-surface border border-border-default text-text-secondary hover:bg-bg-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === 'ALL' ? '전체' : s === 'PENDING' ? '대기' : s === 'ACTIVE' ? '활성' : s === 'REJECTED' ? '거절' : '비활성'}
|
||||||
|
{s !== 'ALL' && (
|
||||||
|
<span className="ml-1 text-xs opacity-70">
|
||||||
|
({users.filter((u) => u.status === s).length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">사용자</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">롤</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">가입일</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-text-muted">
|
||||||
|
{users.length === 0 ? '등록된 사용자가 없습니다.' : '해당 상태의 사용자가 없습니다.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="w-8 h-8 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 bg-accent-soft rounded-full flex items-center justify-center text-xs font-medium text-accent">
|
||||||
|
{user.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-text-primary">{user.name}</p>
|
||||||
|
<p className="text-xs text-text-muted">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{statusBadge(user.status)}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
{user.roles.length > 0
|
||||||
|
? user.roles.map((r) => r.name).join(', ')
|
||||||
|
: <span className="text-text-muted">미배정</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted text-xs">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('ko-KR')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{user.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(user.id, 'approve')}
|
||||||
|
className="px-2.5 py-1 bg-success/10 text-success rounded text-xs font-medium hover:bg-success/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
승인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(user.id, 'reject')}
|
||||||
|
className="px-2.5 py-1 bg-danger/10 text-danger rounded text-xs font-medium hover:bg-danger/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{user.status === 'ACTIVE' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(user.id, 'disable')}
|
||||||
|
className="px-2.5 py-1 bg-bg-tertiary text-text-muted rounded text-xs font-medium hover:bg-border-default cursor-pointer"
|
||||||
|
>
|
||||||
|
비활성화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRoleModalUser(user);
|
||||||
|
setSelectedRoleIds(user.roles.map((r) => r.id));
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 bg-accent-soft text-accent rounded text-xs font-medium hover:bg-accent/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
롤 배정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 롤 배정 모달 */}
|
||||||
|
{roleModalUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-surface rounded-xl shadow-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold text-text-primary mb-4">
|
||||||
|
롤 배정 — {roleModalUser.name}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<label key={role.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-bg-tertiary cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRoleIds.includes(role.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedRoleIds(
|
||||||
|
e.target.checked
|
||||||
|
? [...selectedRoleIds, role.id]
|
||||||
|
: selectedRoleIds.filter((id) => id !== role.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="rounded accent-accent"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-primary">{role.name}</p>
|
||||||
|
<p className="text-xs text-text-muted">{role.description}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{roles.length === 0 && (
|
||||||
|
<p className="text-sm text-text-muted py-4 text-center">등록된 롤이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleModalUser(null)}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-bg-tertiary cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRoleSave}
|
||||||
|
className="px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover cursor-pointer"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -50,3 +50,31 @@ export interface IssueComment {
|
|||||||
author: User;
|
author: User;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Permission {
|
||||||
|
id: number;
|
||||||
|
roleId: number;
|
||||||
|
urlPattern: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsResponse {
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
pendingUsers: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageStat {
|
||||||
|
pagePath: string;
|
||||||
|
viewCount: number;
|
||||||
|
lastAccessed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginHistory {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
userName: string;
|
||||||
|
email: string;
|
||||||
|
loginAt: string;
|
||||||
|
ipAddress: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,10 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
throw new Error(body || `HTTP ${res.status}`);
|
throw new Error(body || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export const DEV_NAV: NavItem[] = [
|
|||||||
{ path: '/dev/git-workflow', label: 'Git 워크플로우' },
|
{ path: '/dev/git-workflow', label: 'Git 워크플로우' },
|
||||||
{ path: '/dev/chat-bot', label: 'Chat 봇 연동' },
|
{ path: '/dev/chat-bot', label: 'Chat 봇 연동' },
|
||||||
{ path: '/dev/starting-project', label: '프로젝트 시작하기' },
|
{ path: '/dev/starting-project', label: '프로젝트 시작하기' },
|
||||||
|
{ path: '/dev/design-system', label: '디자인 시스템' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ADMIN_NAV: NavItem[] = [
|
export const ADMIN_NAV: NavItem[] = [
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user