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:
htlee 2026-02-14 17:38:28 +09:00
부모 4629046550
커밋 456cfdddd9
45개의 변경된 파일3565개의 추가작업 그리고 232개의 파일을 삭제

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

파일 보기

@ -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

파일 보기

@ -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(./**/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/` 디렉토리 존재 여부 확인
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
### 2. CLAUDE.md 생성
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
- 테스트 실행 명령어
- lint 실행 명령어 (감지된 도구 기반)
- 프로젝트 디렉토리 구조 요약
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
### 3. .claude/ 디렉토리 구성
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우:
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow)
### 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. 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
git config core.hooksPath .githooks
```
@ -46,7 +166,7 @@ git config core.hooksPath .githooks
chmod +x .githooks/*
```
### 5. 프로젝트 타입별 추가 설정
### 6. 프로젝트 타입별 추가 설정
#### java-maven
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
@ -63,7 +183,7 @@ chmod +x .githooks/*
- `.npmrc` Nexus 레지스트리 설정 확인
- `npm install && npm run build` 성공 확인
### 6. .gitignore 확인
### 7. .gitignore 확인
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
```
.claude/settings.local.json
@ -73,18 +193,54 @@ chmod +x .githooks/*
*.local
```
### 7. workflow-version.json 생성
`.claude/workflow-version.json` 파일을 생성하여 현재 글로벌 워크플로우 버전 기록:
### 8. Git exclude 설정
`.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
{
"applied_global_version": "1.0.0",
"applied_date": "현재날짜",
"project_type": "감지된타입"
"applied_global_version": "<조회된 버전>",
"applied_date": "<현재날짜>",
"project_type": "<감지된타입>",
"gitea_url": "https://gitea.gc-si.dev"
}
```
### 8. 검증 및 요약
### 12. 검증 및 요약
- 생성/수정된 파일 목록 출력
- `git config core.hooksPath` 확인
- 빌드 명령 실행 가능 확인
- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등)
- Hook 스크립트 실행 권한 확인
- 다음 단계 안내:
- 개발 시작, 첫 커밋 방법
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`

파일 보기

@ -13,11 +13,11 @@ Gitea API로 template-common 리포의 workflow-version.json 조회:
```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")
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. 버전 비교
로컬 `.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` 필드 확인
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. 파일 다운로드 및 적용
Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
#### 4-1. 규칙 파일 (덮어쓰기)
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
@ -42,13 +53,17 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
#### 4-2. settings.json (부분 갱신)
- `deny` 목록: 글로벌 최신으로 교체
- `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. 스킬 파일 (덮어쓰기)
```
.claude/skills/create-mr/SKILL.md
.claude/skills/fix-issue/SKILL.md
.claude/skills/sync-team-workflow/SKILL.md
.claude/skills/init-project/SKILL.md
```
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
@ -56,13 +71,23 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드:
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. 로컬 버전 업데이트
`.claude/workflow-version.json` 갱신:
```json
{
"applied_global_version": "새버전",
"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",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev"

파일 보기

@ -22,11 +22,9 @@ fi
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
# - 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")
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 "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"

파일 보기

@ -38,7 +38,7 @@ npm run lint # ESLint 검사
## 현재 구현 상태
### 완료 (scaffold)
### 완료
- 프로젝트 초기화 (Vite + React + TypeScript + Tailwind CSS v4)
- 인증 시스템 뼈대: AuthProvider, useAuth, ProtectedRoute, AdminRoute
- 페이지 뼈대: LoginPage, PendingPage, DeniedPage, HomePage, GuidePage
@ -46,39 +46,21 @@ npm run lint # ESLint 검사
- 라우팅 구성 (App.tsx): `/login`, `/pending`, `/denied`, `/`, `/dev/:section`, `/admin/*`
- 유틸: api.ts (fetch 래퍼), navigation.ts (메뉴 + ant-style 패턴 매칭)
- 타입 정의: User, Role, AuthResponse, NavItem, Issue
- 공통 컴포넌트: CodeBlock, Alert, StepGuide, CopyButton
- 가이드 콘텐츠 7개 섹션 (실제 시스템 정보 검증 완료)
- 디자인 시스템: CSS 변수 기반 테마 (다크모드 준비)
- 빌드 검증: `tsc -b && vite build` 성공
### 미구현 (별도 세션에서 작업)
아래 순서대로 구현 필요:
#### 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단계: 관리자 페이지
#### 1단계: 관리자 페이지
- `src/pages/admin/UserManagement.tsx` — 사용자 목록, 승인/거절, 롤 배정
- `src/pages/admin/RoleManagement.tsx` — 롤 CRUD
- `src/pages/admin/PermissionManagement.tsx` — 롤별 URL 패턴 CRUD
- `src/pages/admin/StatsPage.tsx` — 통계 대시보드
#### 4단계: 다크모드 + 반응형
#### 2단계: 다크모드 + 반응형
- `src/hooks/useTheme.ts` — 다크/라이트 모드 토글 (localStorage 저장)
- Header에 토글 버튼 추가
- 모바일 반응형: 사이드바 접힘 (hamburger 메뉴)
@ -136,7 +118,7 @@ src/
├── components/
│ ├── layout/
│ │ └── AppLayout.tsx ✅ (사이드바 + 메인 콘텐츠)
│ └── common/ (CodeBlock, Alert, StepGuide, CopyButton)
│ └── common/ (CodeBlock, Alert, StepGuide, CopyButton)
├── pages/
│ ├── LoginPage.tsx ✅
│ ├── PendingPage.tsx ✅
@ -144,7 +126,7 @@ src/
│ ├── HomePage.tsx ✅ (퀵링크 카드)
│ ├── GuidePage.tsx ✅ (section 기반 동적 렌더링 뼈대)
│ └── admin/ ⬜ (UserManagement, RoleManagement 등)
├── content/ ⬜ (7개 가이드 TSX)
├── content/ ✅ (7개 가이드 TSX — 실제 시스템 정보 검증 완료)
├── hooks/ ⬜ (useTheme, useScrollSpy)
├── types/index.ts ✅ (User, Role, AuthResponse, NavItem, Issue)
├── utils/
@ -185,6 +167,23 @@ POST /api/activity/track → { pagePath } → void
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 스타일 가이드
- Tailwind CSS v4 (index.css에 `@import "tailwindcss"`)
- 사이드바: 좌측 고정 w-64, 흰색 배경

파일 보기

@ -2,15 +2,21 @@ import { BrowserRouter, Route, Routes } from 'react-router';
import { AuthProvider } from './auth/AuthProvider';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { AdminRoute } from './auth/AdminRoute';
import { ThemeProvider } from './hooks/ThemeProvider';
import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/LoginPage';
import { PendingPage } from './pages/PendingPage';
import { DeniedPage } from './pages/DeniedPage';
import { HomePage } from './pages/HomePage';
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() {
return (
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
@ -27,16 +33,17 @@ function App() {
{/* Admin */}
<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/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/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/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/users" element={<UserManagement />} />
<Route path="/admin/roles" element={<RoleManagement />} />
<Route path="/admin/permissions" element={<PermissionManagement />} />
<Route path="/admin/stats" element={<StatsPage />} />
</Route>
</Route>
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
);
}

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 {
createContext,
useCallback,
useEffect,
useMemo,
@ -8,34 +7,52 @@ import {
} from 'react';
import type { AuthResponse, User } from '../types';
import { api } from '../utils/api';
import { AuthContext } from './AuthContext';
interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (googleToken: string) => Promise<void>;
logout: () => void;
const DEV_MOCK_USER: User = {
id: 1,
email: 'htlee@gcsc.co.kr',
name: '이현태 (DEV)',
avatarUrl: null,
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 }) {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<User | null>(() =>
isDevMockSession() ? DEV_MOCK_USER : null,
);
const [token, setToken] = useState<string | null>(
() => localStorage.getItem('token'),
);
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(
() => isDevMockSession() || !localStorage.getItem('token'),
);
const logout = useCallback(() => {
const hadToken = !!localStorage.getItem('token') && !isDevMockSession();
localStorage.removeItem('token');
localStorage.removeItem('dev-user');
setToken(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) => {
@ -48,22 +65,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
useEffect(() => {
if (!token) {
setLoading(false);
return;
}
if (!token || isDevMockSession()) return;
let cancelled = false;
api
.get<User>('/auth/me')
.then(setUser)
.catch(() => {
logout();
.then((data) => {
if (!cancelled) setUser(data);
})
.finally(() => setLoading(false));
.catch(() => {
if (!cancelled) logout();
})
.finally(() => {
if (!cancelled) setInitialized(true);
});
return () => {
cancelled = true;
};
}, [token, logout]);
const loading = !initialized;
const value = useMemo(
() => ({ user, token, loading, login, logout }),
[user, token, loading, login, logout],
() => ({ user, token, loading, login, devLogin: import.meta.env.DEV ? devLogin : undefined, logout }),
[user, token, loading, login, devLogin, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

파일 보기

@ -6,8 +6,8 @@ export function ProtectedRoute() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
<div className="animate-spin h-8 w-8 border-4 border-accent border-t-transparent rounded-full" />
</div>
);
}

파일 보기

@ -1,5 +1,5 @@
import { useContext } from 'react';
import { AuthContext } from './AuthProvider';
import { AuthContext } from './AuthContext';
export function useAuth() {
return useContext(AuthContext);

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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,35 +1,60 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router';
import { useAuth } from '../../auth/useAuth';
import { useTheme } from '../../hooks/useTheme';
import { DEV_NAV, ADMIN_NAV } from '../../utils/navigation';
export function AppLayout() {
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
<div className="p-5 border-b border-gray-100">
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-blue-600 rounded-lg flex items-center justify-center">
<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-gray-900"> </span>
<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-gray-400 uppercase tracking-wider mb-2">
<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 ${
`block px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors ${
isActive
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-sidebar-active-bg text-sidebar-active-text font-medium'
: 'text-sidebar-text hover:bg-white/10'
}`
}
>
@ -38,18 +63,19 @@ export function AppLayout() {
))}
{user?.isAdmin && (
<>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mt-6 mb-2">
<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 ${
`block px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors ${
isActive
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-sidebar-active-bg text-sidebar-active-text font-medium'
: 'text-sidebar-text hover:bg-white/10'
}`
}
>
@ -59,27 +85,29 @@ export function AppLayout() {
</>
)}
</nav>
<div className="p-4 border-t border-gray-100">
<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"
/>
<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">
<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-gray-900 truncate">
{user.name}
</p>
<p className="text-sm font-medium text-white truncate">{user.name}</p>
<button
onClick={logout}
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer"
className="text-xs text-sidebar-text/70 hover:text-danger cursor-pointer"
>
</button>
@ -87,12 +115,51 @@ export function AppLayout() {
</div>
)}
</div>
</>
);
return (
<div className="min-h-screen bg-bg-primary flex">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar - desktop */}
<aside className="hidden lg:flex w-64 bg-sidebar-bg flex-col flex-shrink-0 sticky top-0 h-screen">
{sidebarContent}
</aside>
{/* Sidebar - mobile */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 bg-sidebar-bg flex flex-col transform transition-transform duration-200 lg:hidden ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{sidebarContent}
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile header */}
<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>
);
}

파일 보기

@ -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">&rarr;</span>
<span>gitea-chat-sync</span>
<span className="text-text-muted">&rarr;</span>
<span className="text-accent font-semibold">Google Chat API</span>
<span className="text-text-muted">&rarr;</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>
&rarr; <strong> </strong> &rarr; <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 &rarr; 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 &rarr; Chat , Chat에서
Gitea로의 ( , ) .
</Alert>
</div>
);
}

파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -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>
);
}

파일 보기

@ -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
파일 보기

@ -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>
);
}

파일 보기

@ -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
파일 보기

@ -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: () => {},
});

파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
export function useTheme() {
return useContext(ThemeContext);
}

파일 보기

@ -1 +1,106 @@
@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 />;
return (
<div className="min-h-screen bg-gray-50 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="min-h-screen bg-bg-primary flex items-center justify-center px-4">
<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">
<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" />
</svg>
</div>
<h1 className="text-xl font-bold text-gray-900 mb-2"> </h1>
<p className="text-gray-500 text-sm mb-6">
<h1 className="text-xl font-bold text-text-primary mb-2"> </h1>
<p className="text-text-muted text-sm mb-6">
{user.status === 'REJECTED' ? '거절' : '비활성화'}.
<br />
.
</p>
<button
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>

파일 보기

@ -1,27 +1,39 @@
import { lazy, Suspense } from 'react';
import { useParams } from 'react-router';
const GUIDE_TITLES: Record<string, string> = {
'env-intro': '개발환경 소개',
'initial-setup': '초기 환경 설정',
'gitea-usage': 'Gitea 사용법',
'nexus-usage': 'Nexus 사용법',
'git-workflow': 'Git 워크플로우',
'chat-bot': 'Chat 봇 연동',
'starting-project': '프로젝트 시작하기',
const CONTENT_MAP: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
'env-intro': lazy(() => import('../content/DevEnvIntro')),
'initial-setup': lazy(() => import('../content/InitialSetup')),
'gitea-usage': lazy(() => import('../content/GiteaUsage')),
'nexus-usage': lazy(() => import('../content/NexusUsage')),
'git-workflow': lazy(() => import('../content/GitWorkflow')),
'chat-bot': lazy(() => import('../content/ChatBotIntegration')),
'starting-project': lazy(() => import('../content/StartingProject')),
'design-system': lazy(() => import('../content/DesignSystem')),
};
export function GuidePage() {
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-gray-900 mb-4">
{title || '가이드'}
</h1>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-blue-800 text-sm">
.
</div>
<h1 className="text-3xl font-bold text-text-primary mb-4"> </h1>
<p className="text-text-secondary"> .</p>
</div>
);
}
return (
<Suspense
fallback={
<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>
}
>
<Content />
</Suspense>
);
}

파일 보기

@ -1,3 +1,4 @@
import { Link } from 'react-router';
import { useAuth } from '../auth/useAuth';
export function HomePage() {
@ -5,10 +6,10 @@ export function HomePage() {
return (
<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
</h1>
<p className="text-gray-600 mb-8">
<p className="text-text-secondary mb-8">
{user ? `, ${user.name}` : ''}!
.
</p>
@ -20,15 +21,17 @@ export function HomePage() {
{ title: 'Nexus 사용법', desc: '패키지 프록시 설정', path: '/dev/nexus-usage' },
{ title: 'Git 워크플로우', desc: '브랜치 전략 및 코드 리뷰', path: '/dev/git-workflow' },
{ title: 'Chat 봇 연동', desc: '알림 및 봇 명령어', path: '/dev/chat-bot' },
{ title: '프로젝트 시작하기', desc: '템플릿 기반 새 프로젝트', path: '/dev/starting-project' },
{ title: '디자인 시스템', desc: 'UI 컴포넌트 쇼케이스 및 테마', path: '/dev/design-system' },
].map((item) => (
<a
<Link
key={item.path}
href={item.path}
className="block p-5 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition"
to={item.path}
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>
<p className="text-sm text-gray-500 mt-1">{item.desc}</p>
</a>
<h3 className="font-semibold text-text-primary">{item.title}</h3>
<p className="text-sm text-text-muted mt-1">{item.desc}</p>
</Link>
))}
</div>
</div>

파일 보기

@ -1,3 +1,4 @@
import { useState } from 'react';
import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google';
import { Navigate } from 'react-router';
import { useAuth } from '../auth/useAuth';
@ -6,42 +7,67 @@ const GOOGLE_CLIENT_ID =
'295080817934-1uqaqrkup9jnslajkl1ngpee7gm249fv.apps.googleusercontent.com';
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 (user && user.status === 'ACTIVE') return <Navigate to="/" replace />;
if (user && user.status === 'PENDING')
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 (
<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="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="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>
</div>
<h1 className="text-2xl font-bold text-gray-900">
<h1 className="text-2xl font-bold text-text-primary">
GC SI
</h1>
<p className="text-gray-500 mt-2 text-sm">
<p className="text-text-muted mt-2 text-sm">
@gcsc.co.kr
</p>
</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">
<GoogleLogin
onSuccess={(res) => {
if (res.credential) login(res.credential);
}}
onError={() => {
console.error('Google Login failed');
if (res.credential) handleLogin(res.credential);
}}
onError={() => setError('Google 인증에 실패했습니다.')}
theme="outline"
size="large"
width="280"
/>
</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
</p>
</div>

파일 보기

@ -8,25 +8,25 @@ export function PendingPage() {
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
return (
<div className="min-h-screen bg-gray-50 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="min-h-screen bg-bg-primary flex items-center justify-center px-4">
<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">
<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" />
</svg>
</div>
<h1 className="text-xl font-bold text-gray-900 mb-2"> </h1>
<p className="text-gray-600 mb-1">
<h1 className="text-xl font-bold text-text-primary mb-2"> </h1>
<p className="text-text-secondary mb-1">
<span className="font-medium">{user.email}</span>
</p>
<p className="text-gray-500 text-sm mb-6">
<p className="text-text-muted text-sm mb-6">
.
<br />
.
</p>
<button
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>

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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;
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}`);
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T;
}
return res.json();
}

파일 보기

@ -8,6 +8,7 @@ export const DEV_NAV: NavItem[] = [
{ path: '/dev/git-workflow', label: 'Git 워크플로우' },
{ path: '/dev/chat-bot', label: 'Chat 봇 연동' },
{ path: '/dev/starting-project', label: '프로젝트 시작하기' },
{ path: '/dev/design-system', label: '디자인 시스템' },
];
export const ADMIN_NAV: NavItem[] = [