develop #1

병합
htlee develop 에서 main 로 5 commits 를 머지했습니다 2026-02-14 17:40:36 +09:00
68개의 변경된 파일10454개의 추가작업 그리고 1개의 파일을 삭제

파일 보기

@ -0,0 +1,69 @@
# TypeScript/React 코드 스타일 규칙
## TypeScript 일반
- strict 모드 필수 (`tsconfig.json`)
- `any` 사용 금지 (불가피한 경우 주석으로 사유 명시)
- 타입 정의: `interface` 우선 (type은 유니온/인터섹션에만)
- 들여쓰기: 2 spaces
- 세미콜론: 사용
- 따옴표: single quote
- trailing comma: 사용
## React 규칙
### 컴포넌트
- 함수형 컴포넌트 + hooks 패턴만 사용
- 클래스 컴포넌트 사용 금지
- 컴포넌트 파일 당 하나의 export default 컴포넌트
- Props 타입은 interface로 정의 (ComponentNameProps)
```tsx
interface UserCardProps {
name: string;
email: string;
onEdit?: () => void;
}
const UserCard = ({ name, email, onEdit }: UserCardProps) => {
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
{onEdit && <button onClick={onEdit}>편집</button>}
</div>
);
};
export default UserCard;
```
### Hooks
- 커스텀 훅은 `use` 접두사 (예: `useAuth`, `useFetch`)
- 훅은 `src/hooks/` 디렉토리에 분리
- 복잡한 상태 로직은 커스텀 훅으로 추출
### 상태 관리
- 컴포넌트 로컬 상태: `useState`
- 공유 상태: Context API 또는 Zustand
- 서버 상태: React Query (TanStack Query) 권장
### 이벤트 핸들러
- `handle` 접두사: `handleClick`, `handleSubmit`
- Props로 전달 시 `on` 접두사: `onClick`, `onSubmit`
## 스타일링
- CSS Modules 또는 Tailwind CSS (프로젝트 설정에 따름)
- 인라인 스타일 지양
- !important 사용 금지
## API 호출
- API 호출 로직은 `src/services/`에 분리
- Axios 또는 fetch wrapper 사용
- 에러 처리: try-catch + 사용자 친화적 에러 메시지
- 환경별 API URL은 `.env`에서 관리
## 기타
- console.log 커밋 금지 (디버깅 후 제거)
- 매직 넘버/문자열 → 상수 파일로 추출
- 사용하지 않는 import, 변수 제거 (ESLint로 검증)
- 이미지/아이콘은 `src/assets/`에 관리

파일 보기

@ -0,0 +1,84 @@
# Git 워크플로우 규칙
## 브랜치 전략
### 브랜치 구조
```
main ← 배포 가능한 안정 브랜치 (보호됨)
└── develop ← 개발 통합 브랜치
├── feature/ISSUE-123-기능설명
├── bugfix/ISSUE-456-버그설명
└── hotfix/ISSUE-789-긴급수정
```
### 브랜치 네이밍
- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`)
- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명`
- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명`
- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`)
### 브랜치 규칙
- main, develop 브랜치에 직접 커밋/푸시 금지
- feature 브랜치는 develop에서 분기
- hotfix 브랜치는 main에서 분기
- 머지는 반드시 MR(Merge Request)을 통해 수행
## 커밋 메시지 규칙
### Conventional Commits 형식
```
type(scope): subject
body (선택)
footer (선택)
```
### type (필수)
| type | 설명 |
|------|------|
| feat | 새로운 기능 추가 |
| fix | 버그 수정 |
| docs | 문서 변경 |
| style | 코드 포맷팅 (기능 변경 없음) |
| refactor | 리팩토링 (기능 변경 없음) |
| test | 테스트 추가/수정 |
| chore | 빌드, 설정 변경 |
| ci | CI/CD 설정 변경 |
| perf | 성능 개선 |
### scope (선택)
- 변경 범위를 나타내는 짧은 단어
- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`)
### subject (필수)
- 변경 내용을 간결하게 설명
- 한국어, 영어 모두 허용
- 72자 이내
- 마침표(.) 없이 끝냄
### 예시
```
feat(auth): JWT 기반 로그인 구현
fix(배치): 야간 배치 타임아웃 수정
docs: README에 빌드 방법 추가
refactor(user-service): 중복 로직 추출
test(결제): 환불 로직 단위 테스트 추가
chore: Gradle 의존성 버전 업데이트
```
## MR(Merge Request) 규칙
### MR 생성
- 제목: 커밋 메시지와 동일한 Conventional Commits 형식
- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호
- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등)
### MR 리뷰
- 최소 1명의 리뷰어 승인 필수
- CI 검증 통과 필수 (설정된 경우)
- 리뷰 코멘트 모두 해결 후 머지
### MR 머지
- Squash Merge 권장 (깔끔한 히스토리)
- 머지 후 소스 브랜치 삭제

53
.claude/rules/naming.md Normal file
파일 보기

@ -0,0 +1,53 @@
# TypeScript/React 네이밍 규칙
## 파일명
| 항목 | 규칙 | 예시 |
|------|------|------|
| 컴포넌트 | PascalCase | `UserCard.tsx`, `LoginForm.tsx` |
| 페이지 | PascalCase | `Dashboard.tsx`, `UserList.tsx` |
| 훅 | camelCase + use 접두사 | `useAuth.ts`, `useFetch.ts` |
| 서비스 | camelCase | `userService.ts`, `authApi.ts` |
| 유틸리티 | camelCase | `formatDate.ts`, `validation.ts` |
| 타입 정의 | camelCase | `user.types.ts`, `api.types.ts` |
| 상수 | camelCase | `routes.ts`, `constants.ts` |
| 스타일 | 컴포넌트명 + .module | `UserCard.module.css` |
| 테스트 | 대상 + .test | `UserCard.test.tsx` |
## 변수/함수
| 항목 | 규칙 | 예시 |
|------|------|------|
| 변수 | camelCase | `userName`, `isLoading` |
| 함수 | camelCase | `getUserList`, `formatDate` |
| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY`, `API_BASE_URL` |
| boolean 변수 | is/has/can/should 접두사 | `isActive`, `hasPermission` |
| 이벤트 핸들러 | handle 접두사 | `handleClick`, `handleSubmit` |
| 이벤트 Props | on 접두사 | `onClick`, `onSubmit` |
## 타입/인터페이스
| 항목 | 규칙 | 예시 |
|------|------|------|
| interface | PascalCase | `UserProfile`, `ApiResponse` |
| Props | 컴포넌트명 + Props | `UserCardProps`, `ButtonProps` |
| 응답 타입 | 도메인 + Response | `UserResponse`, `LoginResponse` |
| 요청 타입 | 동작 + Request | `CreateUserRequest` |
| Enum | PascalCase | `UserStatus`, `HttpMethod` |
| Enum 값 | UPPER_SNAKE_CASE | `ACTIVE`, `PENDING` |
| Generic | 단일 대문자 | `T`, `K`, `V` |
## 디렉토리
- 모두 kebab-case 또는 camelCase (프로젝트 통일)
- 예: `src/components/common/`, `src/hooks/`, `src/services/`
## 컴포넌트 구조 예시
```
src/components/user-card/
├── UserCard.tsx # 컴포넌트
├── UserCard.module.css # 스타일
├── UserCard.test.tsx # 테스트
└── index.ts # re-export
```

파일 보기

@ -0,0 +1,34 @@
# 팀 정책 (Team Policy)
이 규칙은 조직 전체에 적용되는 필수 정책입니다.
프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다.
## 보안 정책
### 금지 행위
- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지
- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지
- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지
- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지
- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지)
### 인증 정보 관리
- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리
- 설정 파일은 `.gitignore`에 반드시 포함
- 예시 파일(`.env.example`, `application.yml.example`)만 커밋
## 코드 품질 정책
### 필수 검증
- 커밋 전 빌드(컴파일) 성공 확인
- 린트 경고 0개 유지 (CI에서도 검증)
- 테스트 코드가 있는 프로젝트는 테스트 통과 필수
### 코드 리뷰
- main 브랜치 머지 시 최소 1명 리뷰 필수
- 리뷰어 승인 없이 머지 불가
## 문서화 정책
- 공개 API(controller endpoint)에는 반드시 설명 주석 작성
- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성
- README.md에 프로젝트 빌드/실행 방법 유지

64
.claude/rules/testing.md Normal file
파일 보기

@ -0,0 +1,64 @@
# TypeScript/React 테스트 규칙
## 테스트 프레임워크
- Vitest (Vite 프로젝트) 또는 Jest
- React Testing Library (컴포넌트 테스트)
- MSW (Mock Service Worker, API 모킹)
## 테스트 구조
### 단위 테스트
- 유틸리티 함수, 커스텀 훅 테스트
- 외부 의존성 없이 순수 로직 검증
```typescript
describe('formatDate', () => {
it('날짜를 YYYY-MM-DD 형식으로 변환한다', () => {
const result = formatDate(new Date('2026-02-14'));
expect(result).toBe('2026-02-14');
});
it('유효하지 않은 날짜는 빈 문자열을 반환한다', () => {
const result = formatDate(new Date('invalid'));
expect(result).toBe('');
});
});
```
### 컴포넌트 테스트
- React Testing Library 사용
- 사용자 관점에서 테스트 (구현 세부사항이 아닌 동작 테스트)
- `getByRole`, `getByText` 등 접근성 기반 쿼리 우선
```tsx
describe('UserCard', () => {
it('사용자 이름과 이메일을 표시한다', () => {
render(<UserCard name="홍길동" email="hong@test.com" />);
expect(screen.getByText('홍길동')).toBeInTheDocument();
expect(screen.getByText('hong@test.com')).toBeInTheDocument();
});
it('편집 버튼 클릭 시 onEdit 콜백을 호출한다', async () => {
const onEdit = vi.fn();
render(<UserCard name="홍길동" email="hong@test.com" onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: '편집' }));
expect(onEdit).toHaveBeenCalledOnce();
});
});
```
### 테스트 패턴
- **Arrange-Act-Assert** 구조
- 테스트 설명은 한국어로 작성 (`it('사용자 이름을 표시한다')`)
- 하나의 테스트에 하나의 검증
## 테스트 커버리지
- 새로 작성하는 유틸리티 함수: 테스트 필수
- 컴포넌트: 주요 상호작용 테스트 권장
- API 호출: MSW로 모킹하여 에러/성공 시나리오 테스트
## 금지 사항
- 구현 세부사항 테스트 금지 (state 값 직접 확인 등)
- `getByTestId` 남용 금지 (접근성 쿼리 우선)
- 스냅샷 테스트 남용 금지 (변경에 취약)
- `setTimeout`으로 비동기 대기 금지 → `waitFor`, `findBy` 사용

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

84
.claude/settings.json Normal file
파일 보기

@ -0,0 +1,84 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git push -f *)",
"Bash(git push origin --force*)",
"Bash(git reset --hard*)",
"Bash(git clean -fd*)",
"Bash(git checkout -- .)",
"Bash(git restore .)",
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(rm -rf .git*)",
"Bash(rm -rf /*)",
"Bash(rm -rf node_modules)",
"Read(./**/.env)",
"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
}
]
}
]
}
}

파일 보기

@ -0,0 +1,65 @@
---
name: create-mr
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
allowed-tools: "Bash, Read, Grep"
argument-hint: "[target-branch: develop|main] (기본: develop)"
---
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
타겟 브랜치: $ARGUMENTS (기본: develop)
## 수행 단계
### 1. 사전 검증
- 현재 브랜치가 main/develop이 아닌지 확인
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
### 2. 변경 내역 분석
```bash
git log develop..HEAD --oneline
git diff develop..HEAD --stat
```
- 커밋 목록과 변경된 파일 목록 수집
- 주요 변경 사항 요약 작성
### 3. MR 정보 구성
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
- `feature/ISSUE-42-user-login``feat: ISSUE-42 user-login`
- **본문**:
```markdown
## 변경 사항
- (커밋 기반 자동 생성)
## 관련 이슈
- closes #이슈번호 (브랜치명에서 추출)
## 테스트
- [ ] 빌드 성공 확인
- [ ] 기존 테스트 통과
```
### 4. Gitea API로 MR 생성
```bash
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
# Gitea API 호출
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "MR 제목",
"body": "MR 본문",
"head": "현재브랜치",
"base": "타겟브랜치"
}'
```
### 5. 결과 출력
- MR URL 출력
- 리뷰어 지정 안내
- 다음 단계: 리뷰 대기 → 승인 → 머지
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)

파일 보기

@ -0,0 +1,49 @@
---
name: fix-issue
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "<issue-number>"
---
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
## 수행 단계
### 1. 이슈 조회
```bash
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
-H "Authorization: token ${GITEA_TOKEN}"
```
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
- 이슈 내용을 사용자에게 요약하여 보여줌
### 2. 브랜치 생성
이슈 라벨에 따라 브랜치 타입 결정:
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
- 그 외 → `feature/ISSUE-번호-설명`
- 긴급 → `hotfix/ISSUE-번호-설명`
```bash
git checkout develop
git pull origin develop
git checkout -b {type}/ISSUE-{number}-{slug}
```
### 3. 이슈 분석
이슈 내용을 바탕으로:
- 관련 파일 탐색 (Grep, Glob 활용)
- 영향 범위 파악
- 수정 방향 제안
### 4. 수정 계획 제시
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
- 수정할 파일 목록
- 변경 내용 요약
- 예상 영향
### 5. 작업 완료 후
- 변경 사항 요약
- `/create-mr` 실행 안내
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰

파일 보기

@ -0,0 +1,246 @@
---
name: init-project
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
---
팀 표준 워크플로우에 따라 프로젝트를 초기화합니다.
프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지)
## 프로젝트 타입 자동 감지
$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
1. `pom.xml` 존재 → **java-maven**
2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle**
3. `package.json` + `tsconfig.json` 존재 → **react-ts**
4. 감지 실패 → 사용자에게 타입 선택 요청
## 수행 단계
### 1. 프로젝트 분석
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
- 사용 중인 프레임워크, 라이브러리 감지
- 기존 `.claude/` 디렉토리 존재 여부 확인
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
### 2. CLAUDE.md 생성
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
- 테스트 실행 명령어
- lint 실행 명령어 (감지된 도구 기반)
- 프로젝트 디렉토리 구조 요약
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
### 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"
```
### 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
```
`.githooks/` 디렉토리에 실행 권한 부여:
```bash
chmod +x .githooks/*
```
### 6. 프로젝트 타입별 추가 설정
#### java-maven
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
- `.mvn/settings.xml` Nexus 미러 설정 확인
- `mvn compile` 빌드 성공 확인
#### java-gradle
- `.sdkmanrc` 생성
- `gradle.properties.example` Nexus 설정 확인
- `./gradlew compileJava` 빌드 성공 확인
#### react-ts
- `.node-version` 생성 (프로젝트에 맞는 Node 버전)
- `.npmrc` Nexus 레지스트리 설정 확인
- `npm install && npm run build` 성공 확인
### 7. .gitignore 확인
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
```
.claude/settings.local.json
.claude/CLAUDE.local.md
.env
.env.*
*.local
```
### 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": "<조회된 버전>",
"applied_date": "<현재날짜>",
"project_type": "<감지된타입>",
"gitea_url": "https://gitea.gc-si.dev"
}
```
### 12. 검증 및 요약
- 생성/수정된 파일 목록 출력
- `git config core.hooksPath` 확인
- 빌드 명령 실행 가능 확인
- Hook 스크립트 실행 권한 확인
- 다음 단계 안내:
- 개발 시작, 첫 커밋 방법
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`

파일 보기

@ -0,0 +1,98 @@
---
name: sync-team-workflow
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
---
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
## 수행 절차
### 1. 글로벌 버전 조회
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}/gc/template-common/raw/branch/develop/workflow-version.json"
```
### 2. 버전 비교
로컬 `.claude/workflow-version.json``applied_global_version` 필드와 비교:
- 버전 일치 → "최신 버전입니다" 안내 후 종료
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
### 3. 프로젝트 타입 감지
자동 감지 순서:
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. 파일 다운로드 및 적용
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
#### 4-1. 규칙 파일 (덮어쓰기)
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
```
.claude/rules/team-policy.md
.claude/rules/git-workflow.md
.claude/rules/code-style.md (타입별)
.claude/rules/naming.md (타입별)
.claude/rules/testing.md (타입별)
```
#### 4-2. settings.json (부분 갱신)
- `deny` 목록: 글로벌 최신으로 교체
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
- `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 (덮어쓰기 + 실행 권한)
```bash
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": "감지된타입",
"gitea_url": "https://gitea.gc-si.dev"
}
```
### 6. 변경 보고
- `git diff`로 변경 내역 확인
- 업데이트된 파일 목록 출력
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)

파일 보기

@ -0,0 +1,6 @@
{
"applied_global_version": "1.2.0",
"applied_date": "2026-02-14",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev"
}

33
.editorconfig Normal file
파일 보기

@ -0,0 +1,33 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{java,kt}]
indent_style = space
indent_size = 4
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{sh,bash}]
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab
[*.{gradle,groovy}]
indent_style = space
indent_size = 4
[*.xml]
indent_style = space
indent_size = 4

58
.githooks/commit-msg Executable file
파일 보기

@ -0,0 +1,58 @@
#!/bin/bash
#==============================================================================
# commit-msg hook
# Conventional Commits 형식 검증 (한/영 혼용 지원)
#==============================================================================
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Merge 커밋은 검증 건너뜀
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
exit 0
fi
# Revert 커밋은 검증 건너뜀
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
exit 0
fi
# Conventional Commits 정규식
# type(scope): subject
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
# - subject: 1~72자, 한/영 혼용 허용 (필수)
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
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 형식에 맞지 않습니다 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo " 올바른 형식: type(scope): subject"
echo ""
echo " type (필수):"
echo " feat — 새로운 기능"
echo " fix — 버그 수정"
echo " docs — 문서 변경"
echo " style — 코드 포맷팅"
echo " refactor — 리팩토링"
echo " test — 테스트"
echo " chore — 빌드/설정 변경"
echo " ci — CI/CD 변경"
echo " perf — 성능 개선"
echo ""
echo " scope (선택): 한/영 모두 가능"
echo " subject (필수): 1~72자, 한/영 모두 가능"
echo ""
echo " 예시:"
echo " feat(auth): JWT 기반 로그인 구현"
echo " fix(배치): 야간 배치 타임아웃 수정"
echo " docs: README 업데이트"
echo " chore: Gradle 의존성 업데이트"
echo ""
echo " 현재 메시지: $FIRST_LINE"
echo ""
exit 1
fi

25
.githooks/post-checkout Executable file
파일 보기

@ -0,0 +1,25 @@
#!/bin/bash
#==============================================================================
# post-checkout hook
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
#==============================================================================
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
BRANCH_FLAG="$3"
# 파일 체크아웃은 건너뜀
if [ "$BRANCH_FLAG" = "0" ]; then
exit 0
fi
# .githooks 디렉토리 존재 확인
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -d "${REPO_ROOT}/.githooks" ]; then
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
git config core.hooksPath .githooks
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
fi
fi

29
.gitignore vendored Normal file
파일 보기

@ -0,0 +1,29 @@
# Claude Code 워크플로우 (글로벌 제외 해제)
!.claude/
.claude/settings.local.json
.claude/CLAUDE.local.md
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.node-version Normal file
파일 보기

@ -0,0 +1 @@
20

5
.npmrc Normal file
파일 보기

@ -0,0 +1,5 @@
# Nexus npm 프록시 레지스트리
registry=https://nexus.gc-si.dev/repository/npm-public/
# Nexus 인증
//nexus.gc-si.dev/repository/npm-public/:_auth=YWRtaW46R2NzYyE4OTMy

193
CLAUDE.md Normal file
파일 보기

@ -0,0 +1,193 @@
# gc-guide — 개발자 가이드 사이트 (프론트엔드)
## 프로젝트 개요
GC SI 팀 개발자를 위한 온보딩 가이드 사이트.
신규 개발자가 개발 환경 설정, Gitea/Nexus 사용법, Git 워크플로우 등을 학습할 수 있도록 안내.
## 기술 스택
- React 19 + TypeScript + Vite 7
- Tailwind CSS v4 (@tailwindcss/vite 플러그인)
- React Router v7 (BrowserRouter)
- @react-oauth/google (Google OAuth2 인증)
- react-markdown + remark-gfm + rehype-highlight (마크다운 렌더링)
- highlight.js (코드 블록 구문 강조)
## 빌드 & 실행
```bash
npm run dev # 개발 서버 (localhost:5173)
npm run build # 프로덕션 빌드 (dist/)
npm run preview # 빌드 프리뷰
npm run lint # ESLint 검사
```
## 배포
- 서버: guide.gc-si.dev (Nginx 정적 서빙)
- main 브랜치 MR 머지 시 자동 배포 (CI/CD, Gitea Actions)
- 개발 서버 API 프록시: `/api/*``localhost:8080` (vite.config.ts)
- Gitea: https://gitea.gc-si.dev/gc/gc-guide
## 의존성 레포지토리
- npm: https://nexus.gc-si.dev/repository/npm-public/ (.npmrc에 _auth 포함)
## 관련 프로젝트
- gc-guide-api: 백엔드 API (Spring Boot 3.5, JDK 17, PostgreSQL)
- Gitea: https://gitea.gc-si.dev/gc/gc-guide-api
---
## 현재 구현 상태
### 완료
- 프로젝트 초기화 (Vite + React + TypeScript + Tailwind CSS v4)
- 인증 시스템 뼈대: AuthProvider, useAuth, ProtectedRoute, AdminRoute
- 페이지 뼈대: LoginPage, PendingPage, DeniedPage, HomePage, GuidePage
- 레이아웃: AppLayout (좌측 사이드바 + 메인 콘텐츠)
- 라우팅 구성 (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/pages/admin/UserManagement.tsx` — 사용자 목록, 승인/거절, 롤 배정
- `src/pages/admin/RoleManagement.tsx` — 롤 CRUD
- `src/pages/admin/PermissionManagement.tsx` — 롤별 URL 패턴 CRUD
- `src/pages/admin/StatsPage.tsx` — 통계 대시보드
#### 2단계: 다크모드 + 반응형
- `src/hooks/useTheme.ts` — 다크/라이트 모드 토글 (localStorage 저장)
- Header에 토글 버튼 추가
- 모바일 반응형: 사이드바 접힘 (hamburger 메뉴)
- `src/hooks/useScrollSpy.ts` — 우측 목차(ToC) 스크롤 추적
---
## 인증/인가 흐름 (3단계)
```
1단계: Google OAuth (@gcsc.co.kr 필터)
비인증 → LoginPage → "Google로 로그인"
→ Google OAuth2 팝업 → ID Token 수신
→ POST /api/auth/google → 백엔드에서 @gcsc.co.kr 도메인 검증
→ 신규: status=PENDING으로 등록, JWT 발급
→ 기존: JWT 발급
2단계: 관리자 승인
PENDING → /pending 페이지 표시 ("승인 대기 중")
관리자 → /admin/users에서 승인/거절
승인 → status=ACTIVE, 롤 그룹 배정
3단계: 롤 기반 URL 접근 제어
ACTIVE → 사이드바에 접근 가능한 메뉴만 표시
라우트 가드: 사용자 롤의 urlPatterns와 현재 경로 매칭
```
- Google OAuth2 Client ID: `295080817934-1uqaqrkup9jnslajkl1ngpee7gm249fv.apps.googleusercontent.com`
- 사용자 상태: PENDING → ACTIVE / REJECTED / DISABLED
- 초기 관리자: htlee@gcsc.co.kr (auto-approve, isAdmin=true)
## 라우팅 구조
```
/login → LoginPage (공개)
/pending → PendingPage (PENDING 사용자)
/denied → DeniedPage (REJECTED/DISABLED)
/ → HomePage (ACTIVE, 퀵링크 카드)
/dev/:section → GuidePage → 콘텐츠 컴포넌트 (ACTIVE, 롤 기반)
/admin/users → UserManagement (ADMIN만)
/admin/roles → RoleManagement (ADMIN만)
/admin/permissions → PermissionManagement (ADMIN만)
/admin/stats → StatsPage (ADMIN만)
```
## 프로젝트 구조
```
src/
├── auth/
│ ├── AuthProvider.tsx ✅ (Google OAuth → JWT 인증 컨텍스트)
│ ├── useAuth.ts ✅ (인증 훅)
│ ├── ProtectedRoute.tsx ✅ (인증+상태 가드)
│ └── AdminRoute.tsx ✅ (관리자 가드)
├── components/
│ ├── layout/
│ │ └── AppLayout.tsx ✅ (사이드바 + 메인 콘텐츠)
│ └── common/ ✅ (CodeBlock, Alert, StepGuide, CopyButton)
├── pages/
│ ├── LoginPage.tsx ✅
│ ├── PendingPage.tsx ✅
│ ├── DeniedPage.tsx ✅
│ ├── HomePage.tsx ✅ (퀵링크 카드)
│ ├── GuidePage.tsx ✅ (section 기반 동적 렌더링 뼈대)
│ └── admin/ ⬜ (UserManagement, RoleManagement 등)
├── content/ ✅ (7개 가이드 TSX — 실제 시스템 정보 검증 완료)
├── hooks/ ⬜ (useTheme, useScrollSpy)
├── types/index.ts ✅ (User, Role, AuthResponse, NavItem, Issue)
├── utils/
│ ├── api.ts ✅ (fetch 래퍼 + 401 자동 리다이렉트)
│ └── navigation.ts ✅ (메뉴 구성 + ant-style 패턴 매칭)
├── App.tsx ✅ (BrowserRouter + Routes)
├── main.tsx ✅
└── index.css ✅ (Tailwind CSS)
```
## 백엔드 API (gc-guide-api) 엔드포인트
프론트엔드에서 호출하는 API 목록:
```
POST /api/auth/google → { idToken } → { token, user }
GET /api/auth/me → User (JWT Authorization 헤더)
POST /api/auth/logout → void
GET /api/admin/users → User[] (ADMIN만)
PUT /api/admin/users/:id/approve → User
PUT /api/admin/users/:id/reject → User
PUT /api/admin/users/:id/disable → User
PUT /api/admin/users/:id/roles → { roleIds: number[] } → User
GET /api/admin/roles → Role[]
POST /api/admin/roles → { name, description } → Role
PUT /api/admin/roles/:id → { name, description } → Role
DELETE /api/admin/roles/:id → void
GET /api/admin/roles/:id/permissions → { urlPatterns: string[] }
POST /api/admin/roles/:id/permissions → { urlPattern: string }
DELETE /api/admin/permissions/:id → void
GET /api/admin/stats → { totalUsers, activeUsers, ... }
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, 흰색 배경
- 활성 메뉴: bg-blue-50, text-blue-700
- 카드: bg-white, border, rounded-xl, hover shadow
- 반응형: 추후 모바일 대응 (사이드바 접힘)
- 코드 블록: highlight.js 테마 (atom-one-dark 권장)

파일 보기

@ -1,3 +1,27 @@
# gc-guide
개발자 가이드 사이트 (React + TypeScript + Vite)
GC SI 개발자 가이드 사이트.
## 기술 스택
- React 19 + TypeScript + Vite
- Tailwind CSS v4
- React Router v7
- Google OAuth2 인증
## 개발
```bash
npm install
npm run dev
```
## 빌드
```bash
npm run build
```
## 배포
main 브랜치 MR 머지 시 자동 배포 → https://guide.gc-si.dev

23
eslint.config.js Normal file
파일 보기

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
파일 보기

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gc-guide-init</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5396
package-lock.json generated Normal file

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

39
package.json Normal file
파일 보기

@ -0,0 +1,39 @@
{
"name": "gc-guide",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-oauth/google": "^0.13.4",
"@tailwindcss/vite": "^4.1.18",
"highlight.js": "^11.11.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router": "^7.13.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

50
src/App.tsx Normal file
파일 보기

@ -0,0 +1,50 @@
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>
{/* Public */}
<Route path="/login" element={<LoginPage />} />
<Route path="/pending" element={<PendingPage />} />
<Route path="/denied" element={<DeniedPage />} />
{/* Protected */}
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="/dev/:section" element={<GuidePage />} />
{/* Admin */}
<Route element={<AdminRoute />}>
<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>
);
}
export default App;

12
src/auth/AdminRoute.tsx Normal file
파일 보기

@ -0,0 +1,12 @@
import { Navigate, Outlet } from 'react-router';
import { useAuth } from './useAuth';
export function AdminRoute() {
const { user } = useAuth();
if (!user?.isAdmin) {
return <Navigate to="/" replace />;
}
return <Outlet />;
}

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

95
src/auth/AuthProvider.tsx Normal file
파일 보기

@ -0,0 +1,95 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { AuthResponse, User } from '../types';
import { api } from '../utils/api';
import { AuthContext } from './AuthContext';
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 function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(() =>
isDevMockSession() ? DEV_MOCK_USER : null,
);
const [token, setToken] = useState<string | null>(
() => localStorage.getItem('token'),
);
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) => {
const res = await api.post<AuthResponse>('/auth/google', {
idToken: googleToken,
});
localStorage.setItem('token', res.token);
setToken(res.token);
setUser(res.user);
}, []);
useEffect(() => {
if (!token || isDevMockSession()) return;
let cancelled = false;
api
.get<User>('/auth/me')
.then((data) => {
if (!cancelled) setUser(data);
})
.catch(() => {
if (!cancelled) logout();
})
.finally(() => {
if (!cancelled) setInitialized(true);
});
return () => {
cancelled = true;
};
}, [token, logout]);
const loading = !initialized;
const value = useMemo(
() => ({ 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>;
}

파일 보기

@ -0,0 +1,28 @@
import { Navigate, Outlet } from 'react-router';
import { useAuth } from './useAuth';
export function ProtectedRoute() {
const { user, loading } = useAuth();
if (loading) {
return (
<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>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.status === 'PENDING') {
return <Navigate to="/pending" replace />;
}
if (user.status === 'REJECTED' || user.status === 'DISABLED') {
return <Navigate to="/denied" replace />;
}
return <Outlet />;
}

6
src/auth/useAuth.ts Normal file
파일 보기

@ -0,0 +1,6 @@
import { useContext } from 'react';
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>
);
}

파일 보기

@ -0,0 +1,165 @@
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);
const cycleTheme = () => {
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
setTheme(next);
};
const themeIcon =
theme === 'light' ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : theme === 'dark' ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
const themeLabel = theme === 'light' ? '라이트' : theme === 'dark' ? '다크' : '시스템';
const sidebarContent = (
<>
<div className="p-5 border-b border-white/10">
<a href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-sidebar-active-bg rounded-lg flex items-center justify-center">
<span className="text-white text-sm font-bold">GC</span>
</div>
<span className="font-semibold text-white"> </span>
</a>
</div>
<nav className="flex-1 p-4 overflow-y-auto">
<p className="text-xs font-semibold text-sidebar-text/60 uppercase tracking-wider mb-2">
</p>
{DEV_NAV.map((item) => (
<NavLink
key={item.path}
to={item.path}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`block px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors ${
isActive
? 'bg-sidebar-active-bg text-sidebar-active-text font-medium'
: 'text-sidebar-text hover:bg-white/10'
}`
}
>
{item.label}
</NavLink>
))}
{user?.isAdmin && (
<>
<p className="text-xs font-semibold text-sidebar-text/60 uppercase tracking-wider mt-6 mb-2">
</p>
{ADMIN_NAV.map((item) => (
<NavLink
key={item.path}
to={item.path}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`block px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors ${
isActive
? 'bg-sidebar-active-bg text-sidebar-active-text font-medium'
: 'text-sidebar-text hover:bg-white/10'
}`
}
>
{item.label}
</NavLink>
))}
</>
)}
</nav>
<div className="p-4 border-t border-white/10">
<button
onClick={cycleTheme}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-sidebar-text hover:bg-white/10 transition-colors cursor-pointer mb-3"
title={`현재: ${themeLabel}`}
>
{themeIcon}
<span>{themeLabel} </span>
</button>
{user && (
<div className="flex items-center gap-3">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center text-xs font-medium text-white">
{user.name[0]}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user.name}</p>
<button
onClick={logout}
className="text-xs text-sidebar-text/70 hover:text-danger cursor-pointer"
>
</button>
</div>
</div>
)}
</div>
</>
);
return (
<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);
}

106
src/index.css Normal file
파일 보기

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

10
src/main.tsx Normal file
파일 보기

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

33
src/pages/DeniedPage.tsx Normal file
파일 보기

@ -0,0 +1,33 @@
import { useAuth } from '../auth/useAuth';
import { Navigate } from 'react-router';
export function DeniedPage() {
const { user, logout } = useAuth();
if (!user) return <Navigate to="/login" replace />;
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
return (
<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-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-link hover:text-accent-hover cursor-pointer"
>
</button>
</div>
</div>
);
}

39
src/pages/GuidePage.tsx Normal file
파일 보기

@ -0,0 +1,39 @@
import { lazy, Suspense } from 'react';
import { useParams } from 'react-router';
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 Content = section ? CONTENT_MAP[section] : null;
if (!Content) {
return (
<div className="max-w-4xl mx-auto py-12 px-6">
<h1 className="text-3xl font-bold text-text-primary mb-4"> </h1>
<p className="text-text-secondary"> .</p>
</div>
);
}
return (
<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>
);
}

39
src/pages/HomePage.tsx Normal file
파일 보기

@ -0,0 +1,39 @@
import { Link } from 'react-router';
import { useAuth } from '../auth/useAuth';
export function HomePage() {
const { user } = useAuth();
return (
<div className="max-w-4xl mx-auto py-12 px-6">
<h1 className="text-3xl font-bold text-text-primary mb-4">
GC SI
</h1>
<p className="text-text-secondary mb-8">
{user ? `, ${user.name}` : ''}!
.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ title: '개발환경 소개', desc: '인프라 구성 및 서비스 개요', path: '/dev/env-intro' },
{ title: '초기 환경 설정', desc: 'SSH, Git, SDK 설치', path: '/dev/initial-setup' },
{ title: 'Gitea 사용법', desc: 'Git 저장소 관리', path: '/dev/gitea-usage' },
{ 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) => (
<Link
key={item.path}
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-text-primary">{item.title}</h3>
<p className="text-sm text-text-muted mt-1">{item.desc}</p>
</Link>
))}
</div>
</div>
);
}

77
src/pages/LoginPage.tsx Normal file
파일 보기

@ -0,0 +1,77 @@
import { useState } from 'react';
import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google';
import { Navigate } from 'react-router';
import { useAuth } from '../auth/useAuth';
const GOOGLE_CLIENT_ID =
'295080817934-1uqaqrkup9jnslajkl1ngpee7gm249fv.apps.googleusercontent.com';
export function LoginPage() {
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-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-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-text-primary">
GC SI
</h1>
<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) handleLogin(res.credential);
}}
onError={() => setError('Google 인증에 실패했습니다.')}
theme="outline"
size="large"
width="280"
/>
</div>
{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>
</div>
</GoogleOAuthProvider>
);
}

36
src/pages/PendingPage.tsx Normal file
파일 보기

@ -0,0 +1,36 @@
import { useAuth } from '../auth/useAuth';
import { Navigate } from 'react-router';
export function PendingPage() {
const { user, logout } = useAuth();
if (!user) return <Navigate to="/login" replace />;
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
return (
<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-text-primary mb-2"> </h1>
<p className="text-text-secondary mb-1">
<span className="font-medium">{user.email}</span>
</p>
<p className="text-text-muted text-sm mb-6">
.
<br />
.
</p>
<button
onClick={logout}
className="text-sm text-link hover:text-accent-hover cursor-pointer"
>
</button>
</div>
</div>
);
}

파일 보기

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

80
src/types/index.ts Normal file
파일 보기

@ -0,0 +1,80 @@
export interface User {
id: number;
email: string;
name: string;
avatarUrl: string | null;
status: UserStatus;
isAdmin: boolean;
roles: Role[];
createdAt: string;
lastLoginAt: string | null;
}
export type UserStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED';
export interface Role {
id: number;
name: string;
description: string;
urlPatterns: string[];
}
export interface AuthResponse {
token: string;
user: User;
}
export interface NavItem {
path: string;
label: string;
icon?: string;
children?: NavItem[];
}
export interface Issue {
id: number;
title: string;
body: string;
status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED';
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
author: User;
assignee: User | null;
comments: IssueComment[];
createdAt: string;
updatedAt: string;
}
export interface IssueComment {
id: number;
body: string;
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;
}

37
src/utils/api.ts Normal file
파일 보기

@ -0,0 +1,37 @@
const API_BASE = '/api';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.text();
throw new Error(body || `HTTP ${res.status}`);
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T;
}
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

32
src/utils/navigation.ts Normal file
파일 보기

@ -0,0 +1,32 @@
import type { NavItem } from '../types';
export const DEV_NAV: NavItem[] = [
{ path: '/dev/env-intro', label: '개발환경 소개' },
{ path: '/dev/initial-setup', label: '초기 환경 설정' },
{ path: '/dev/gitea-usage', label: 'Gitea 사용법' },
{ path: '/dev/nexus-usage', label: 'Nexus 사용법' },
{ 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[] = [
{ path: '/admin/users', label: '사용자 관리' },
{ path: '/admin/roles', label: '롤 관리' },
{ path: '/admin/permissions', label: '권한 관리' },
{ path: '/admin/stats', label: '통계' },
];
export function canAccessPath(path: string, urlPatterns: string[]): boolean {
return urlPatterns.some((pattern) => matchAntPattern(pattern, path));
}
function matchAntPattern(pattern: string, path: string): boolean {
if (pattern === '/**') return true;
const regex = pattern
.replace(/\*\*/g, '.*')
.replace(/(?<!\.)(\*)/g, '[^/]*')
.replace(/\?/g, '[^/]');
return new RegExp(`^${regex}$`).test(path);
}

28
tsconfig.app.json Normal file
파일 보기

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
파일 보기

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
파일 보기

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
vite.config.ts Normal file
파일 보기

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})