develop #1
69
.claude/rules/code-style.md
Normal file
69
.claude/rules/code-style.md
Normal file
@ -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/`에 관리
|
||||||
84
.claude/rules/git-workflow.md
Normal file
84
.claude/rules/git-workflow.md
Normal file
@ -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
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
|
||||||
|
```
|
||||||
34
.claude/rules/team-policy.md
Normal file
34
.claude/rules/team-policy.md
Normal file
@ -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
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
14
.claude/scripts/on-commit.sh
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
23
.claude/scripts/on-post-compact.sh
Executable file
23
.claude/scripts/on-post-compact.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
|
fi
|
||||||
8
.claude/scripts/on-pre-compact.sh
Executable file
8
.claude/scripts/on-pre-compact.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
84
.claude/settings.json
Normal file
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
65
.claude/skills/create-mr/SKILL.md
Normal file
65
.claude/skills/create-mr/SKILL.md
Normal file
@ -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 접근 토큰 (없으면 안내)
|
||||||
49
.claude/skills/fix-issue/SKILL.md
Normal file
49
.claude/skills/fix-issue/SKILL.md
Normal file
@ -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 접근 토큰
|
||||||
246
.claude/skills/init-project/SKILL.md
Normal file
246
.claude/skills/init-project/SKILL.md
Normal file
@ -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`
|
||||||
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
@ -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) 표시
|
||||||
|
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)
|
||||||
6
.claude/workflow-version.json
Normal file
6
.claude/workflow-version.json
Normal file
@ -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
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
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
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
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
1
.node-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
20
|
||||||
5
.npmrc
Normal file
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
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 권장)
|
||||||
26
README.md
26
README.md
@ -1,3 +1,27 @@
|
|||||||
# gc-guide
|
# 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
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
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
5396
package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
39
package.json
Normal file
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
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
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
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
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>;
|
||||||
|
}
|
||||||
28
src/auth/ProtectedRoute.tsx
Normal file
28
src/auth/ProtectedRoute.tsx
Normal file
@ -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
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);
|
||||||
|
}
|
||||||
51
src/components/common/Alert.tsx
Normal file
51
src/components/common/Alert.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AlertProps {
|
||||||
|
type: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<AlertProps['type'], ReactNode> = {
|
||||||
|
info: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
success: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLES: Record<AlertProps['type'], string> = {
|
||||||
|
info: 'bg-info/10 border-info/30 text-info',
|
||||||
|
warning: 'bg-warning/10 border-warning/30 text-warning',
|
||||||
|
error: 'bg-danger/10 border-danger/30 text-danger',
|
||||||
|
success: 'bg-success/10 border-success/30 text-success',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Alert({ type, title, children }: AlertProps) {
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-lg p-4 text-sm my-4 ${STYLES[type]}`}>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="flex-shrink-0 mt-0.5">{ICONS[type]}</span>
|
||||||
|
<div>
|
||||||
|
{title && <p className="font-semibold mb-1">{title}</p>}
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/common/CodeBlock.tsx
Normal file
39
src/components/common/CodeBlock.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
|
||||||
|
interface CodeBlockProps {
|
||||||
|
code: string;
|
||||||
|
language?: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlock({ code, language, filename }: CodeBlockProps) {
|
||||||
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeRef.current) {
|
||||||
|
codeRef.current.removeAttribute('data-highlighted');
|
||||||
|
hljs.highlightElement(codeRef.current);
|
||||||
|
}
|
||||||
|
}, [code, language]);
|
||||||
|
|
||||||
|
const trimmedCode = code.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group rounded-lg overflow-hidden border border-gray-700 bg-[#282c34] my-4">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/60 text-gray-400 text-xs border-b border-gray-700">
|
||||||
|
<span>{filename || language || ''}</span>
|
||||||
|
<CopyButton text={trimmedCode} />
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm leading-relaxed m-0">
|
||||||
|
<code
|
||||||
|
ref={codeRef}
|
||||||
|
className={language ? `language-${language}` : ''}
|
||||||
|
>
|
||||||
|
{trimmedCode}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/common/CopyButton.tsx
Normal file
39
src/components/common/CopyButton.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ text, className = '' }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||||
|
copied
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-200'
|
||||||
|
} ${className}`}
|
||||||
|
title="클립보드에 복사"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{copied ? '복사됨' : '복사'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/common/StepGuide.tsx
Normal file
33
src/components/common/StepGuide.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
title: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepGuideProps {
|
||||||
|
steps: Step[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepGuide({ steps }: StepGuideProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0 my-6">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={index} className="flex gap-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent text-white flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className="w-0.5 flex-1 bg-accent/20 mt-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pb-8 flex-1 min-w-0">
|
||||||
|
<h4 className="font-semibold text-text-primary mb-2">{step.title}</h4>
|
||||||
|
<div className="text-sm text-text-secondary">{step.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/common/TableOfContents.tsx
Normal file
95
src/components/common/TableOfContents.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
import { useScrollSpy } from '../../hooks/useScrollSpy';
|
||||||
|
|
||||||
|
interface TocItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeadingsSnapshot(): TocItem[] {
|
||||||
|
const headings = document.querySelectorAll('h2[id], h3[id]');
|
||||||
|
const tocItems: TocItem[] = [];
|
||||||
|
headings.forEach((heading) => {
|
||||||
|
tocItems.push({
|
||||||
|
id: heading.id,
|
||||||
|
text: heading.textContent || '',
|
||||||
|
level: heading.tagName === 'H2' ? 2 : 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return tocItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedItems: TocItem[] = [];
|
||||||
|
let cachedKey = '';
|
||||||
|
|
||||||
|
function subscribe(callback: () => void) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const fresh = getHeadingsSnapshot();
|
||||||
|
const key = fresh.map((i) => i.id).join(',');
|
||||||
|
if (key !== cachedKey) {
|
||||||
|
cachedKey = key;
|
||||||
|
cachedItems = fresh;
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// initial scan
|
||||||
|
const initial = getHeadingsSnapshot();
|
||||||
|
cachedKey = initial.map((i) => i.id).join(',');
|
||||||
|
cachedItems = initial;
|
||||||
|
callback();
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot() {
|
||||||
|
return cachedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableOfContents() {
|
||||||
|
const items = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
const activeId = useScrollSpy('h2[id], h3[id]');
|
||||||
|
|
||||||
|
// Memoize to prevent unnecessary re-renders
|
||||||
|
const stableItems = useMemo(() => items, [items]);
|
||||||
|
|
||||||
|
// Re-scan when route changes (items empty after navigation)
|
||||||
|
useEffect(() => {
|
||||||
|
const fresh = getHeadingsSnapshot();
|
||||||
|
const key = fresh.map((i) => i.id).join(',');
|
||||||
|
if (key !== cachedKey) {
|
||||||
|
cachedKey = key;
|
||||||
|
cachedItems = fresh;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stableItems.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="hidden xl:block fixed right-8 top-24 w-56">
|
||||||
|
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||||
|
목차
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-sm border-l border-border-default">
|
||||||
|
{stableItems.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a
|
||||||
|
href={`#${item.id}`}
|
||||||
|
className={`block py-1 transition-colors ${
|
||||||
|
item.level === 3 ? 'pl-6' : 'pl-3'
|
||||||
|
} ${
|
||||||
|
activeId === item.id
|
||||||
|
? 'text-accent border-l-2 border-accent -ml-px'
|
||||||
|
: 'text-text-muted hover:text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/components/layout/AppLayout.tsx
Normal file
165
src/components/layout/AppLayout.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
src/content/ChatBotIntegration.tsx
Normal file
269
src/content/ChatBotIntegration.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function ChatBotIntegration() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Chat 봇 연동</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
Google Chat 스페이스에서 SI DevBot을 통한 Gitea 알림 수신 및 팀 연결 방법을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 개요 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">개요</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
<strong>SI DevBot</strong>은 Gitea와 Google Chat을 연동하는 봇입니다.
|
||||||
|
Gitea에서 발생하는 이벤트(Push, MR, 이슈 등)를 Chat 스페이스로 자동 알림하고,
|
||||||
|
간단한 명령어로 상태를 확인할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5 mb-6">
|
||||||
|
<div className="font-mono text-sm space-y-1.5 text-text-secondary">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-accent font-semibold">Gitea Webhook</span>
|
||||||
|
<span className="text-text-muted">→</span>
|
||||||
|
<span>gitea-chat-sync</span>
|
||||||
|
<span className="text-text-muted">→</span>
|
||||||
|
<span className="text-accent font-semibold">Google Chat API</span>
|
||||||
|
<span className="text-text-muted">→</span>
|
||||||
|
<span>스페이스 알림</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스페이스 설정 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">스페이스 설정</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'Google Chat 스페이스 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Google Chat에서 <strong>스페이스 만들기</strong>를 클릭합니다.
|
||||||
|
스페이스 이름을 <code className="bg-bg-tertiary px-1 rounded">[GC] 팀이름</code> 형식으로
|
||||||
|
생성하면 봇이 자동으로 Gitea 팀과 매칭합니다.
|
||||||
|
(예: <code className="bg-bg-tertiary px-1 rounded">[GC] developers</code>)
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SI DevBot 추가',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
스페이스 설정 → <strong>앱 및 통합</strong> → <strong>SI DevBot</strong>을
|
||||||
|
검색하여 추가합니다. 봇이 스페이스에 참여하면 환영 메시지와 함께 사용 가능한 명령어를 안내합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '팀 연결 확인',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">
|
||||||
|
스페이스 이름이 <code className="bg-bg-tertiary px-1 rounded">[GC] 팀이름</code> 형식이면
|
||||||
|
자동 연결됩니다. 수동으로 연결하려면{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">link</code> 명령어를 사용합니다.
|
||||||
|
</p>
|
||||||
|
<Alert type="info">
|
||||||
|
Gitea 조직 Webhook은 관리자가 설정합니다. 개별 리포지토리 Webhook 설정은 불필요합니다.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 봇 명령어 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">봇 명령어</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
스페이스에서 <code className="bg-bg-tertiary px-1 rounded">@SI DevBot</code>을 멘션하여 명령어를 사용합니다.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">명령어</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">설명</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">예시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">status</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
Gitea/Chat 연결 상태 및 팀-스페이스 매핑 현황 확인
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">@SI DevBot status</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">teams</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
Gitea 조직의 팀 목록 및 스페이스 연결 상태 조회
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">@SI DevBot teams</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">{'link <팀이름>'}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
현재 스페이스를 Gitea 팀과 수동 연결
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
||||||
|
@SI DevBot link developers
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent">help</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">사용 가능한 명령어 목록 표시</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">@SI DevBot help</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알림 유형 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">알림 유형</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea 조직 Webhook을 통해 다음 이벤트가 연결된 스페이스로 자동 전송됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: '\u{1F4DD}',
|
||||||
|
title: 'Push 알림',
|
||||||
|
desc: '브랜치에 새 커밋이 푸시되면 알림 (커밋 3개까지 상세 표시)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F500}',
|
||||||
|
title: 'MR 생성/머지',
|
||||||
|
desc: 'MR이 생성, 승인, 머지(Squash)될 때 알림',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F41B}',
|
||||||
|
title: '이슈 변경',
|
||||||
|
desc: '이슈 생성, 닫힘, 재오픈 시 알림',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F4AC}',
|
||||||
|
title: '댓글 알림',
|
||||||
|
desc: '이슈/MR에 댓글이 추가되면 알림 (최대 200자)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '\u{1F4E6}',
|
||||||
|
title: '저장소 생성/삭제',
|
||||||
|
desc: '새 저장소가 생성되거나 삭제될 때 알림',
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
className="bg-surface border border-border-default rounded-lg p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-xl flex-shrink-0">{item.icon}</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">{item.title}</h4>
|
||||||
|
<p className="text-xs text-text-secondary mt-0.5">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 팀-스페이스 매핑 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">팀-스페이스 매핑</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea 팀과 Chat 스페이스가 연결되면 해당 팀의 리포지토리 이벤트가 스페이스로 전송됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-1">자동 매칭</h4>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
스페이스 이름이 <code className="bg-bg-tertiary px-1 rounded">[GC] 팀이름</code> 형식이면
|
||||||
|
봇 추가 시 자동으로 Gitea 팀과 연결됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-1">수동 연결</h4>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">@SI DevBot link developers</code>{' '}
|
||||||
|
명령어로 현재 스페이스를 원하는 Gitea 팀에 수동 연결할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-1">주기적 동기화</h4>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
30분마다 Gitea 팀 목록과 Chat 스페이스 매핑을 자동으로 동기화합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 봇 관리 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">봇 관리 (관리자용)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
봇의 명령어나 알림 로직을 수정하려면 소스 코드를 변경하고 서버에 재배포합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">소스 코드</h3>
|
||||||
|
<p className="text-text-secondary mb-3">
|
||||||
|
Gitea <code className="bg-bg-tertiary px-1 rounded">gc/gitea-chat-sync</code> 리포지토리
|
||||||
|
(Python Flask)
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto mb-4">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">파일</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">역할</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">chat_event_handler.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
봇 명령어 처리 (status, teams, link, help)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">webhook_handler.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
Gitea 이벤트 → Chat 알림 변환
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">sync_engine.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">팀-스페이스 동기화 엔진</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">reconciler.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">30분 주기 매핑 동기화</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent text-xs">config.py</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">환경변수 설정</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">배포 방법</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
filename="서버 배포 절차"
|
||||||
|
code={`# 1. Gitea에서 소스 수정 후 develop push → MR → main 머지
|
||||||
|
|
||||||
|
# 2. 서버 접속 후 최신 소스 pull
|
||||||
|
ssh root@211.208.115.83
|
||||||
|
cd /devdata/services/gitea-chat-sync
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 3. Docker 이미지 재빌드 및 재시작
|
||||||
|
docker-compose -f /devdata/services/docker-compose.yml up -d --build gitea-chat-sync
|
||||||
|
|
||||||
|
# 4. 로그 확인
|
||||||
|
docker logs -f gitea-chat-sync --tail 50`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert type="warning" title="현재 제한사항">
|
||||||
|
현재 Phase 2-A (단방향)로 운영 중입니다. Gitea → Chat 알림 전송만 가능하며, Chat에서
|
||||||
|
Gitea로의 양방향 동기화(스페이스 자동 생성, 멤버 동기화)는 관리자 승인 후 활성화 예정입니다.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
476
src/content/DesignSystem.tsx
Normal file
476
src/content/DesignSystem.tsx
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
|
||||||
|
export default function DesignSystem() {
|
||||||
|
const [activeTab, setActiveTab] = useState('buttons');
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'buttons', label: '버튼' },
|
||||||
|
{ id: 'cards', label: '카드' },
|
||||||
|
{ id: 'alerts', label: '알림' },
|
||||||
|
{ id: 'badges', label: '배지' },
|
||||||
|
{ id: 'forms', label: '폼' },
|
||||||
|
{ id: 'tables', label: '테이블' },
|
||||||
|
{ id: 'theme', label: '테마 커스텀' },
|
||||||
|
{ id: 'future', label: '향후 확장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">디자인 시스템</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
프로젝트에서 사용하는 UI 컴포넌트 쇼케이스입니다. 테마 전환 시 실시간으로 반영됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<div className="flex gap-1 overflow-x-auto pb-2 mb-8 border-b border-border-default">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-t-lg cursor-pointer transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'text-text-secondary hover:bg-bg-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
{activeTab === 'buttons' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">버튼</h2>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">기본 버튼</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover">Primary</button>
|
||||||
|
<button className="px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg text-sm font-medium hover:bg-border-default">Secondary</button>
|
||||||
|
<button className="px-4 py-2 border border-border-default text-text-secondary rounded-lg text-sm font-medium hover:bg-bg-tertiary">Outline</button>
|
||||||
|
<button className="px-4 py-2 text-link text-sm font-medium hover:text-accent-hover">Ghost</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover">
|
||||||
|
Primary
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg text-sm font-medium hover:bg-border-default">
|
||||||
|
Secondary
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 border border-border-default text-text-secondary rounded-lg text-sm font-medium hover:bg-bg-tertiary">
|
||||||
|
Outline
|
||||||
|
</button>`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">상태별 버튼</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button className="px-4 py-2 bg-success text-white rounded-lg text-sm font-medium">Success</button>
|
||||||
|
<button className="px-4 py-2 bg-warning text-white rounded-lg text-sm font-medium">Warning</button>
|
||||||
|
<button className="px-4 py-2 bg-danger text-white rounded-lg text-sm font-medium">Danger</button>
|
||||||
|
<button className="px-4 py-2 bg-info text-white rounded-lg text-sm font-medium">Info</button>
|
||||||
|
<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium opacity-50 cursor-not-allowed">Disabled</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">크기 변형</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button className="px-2.5 py-1 bg-accent text-white rounded text-xs font-medium hover:bg-accent-hover">Small</button>
|
||||||
|
<button className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover">Medium</button>
|
||||||
|
<button className="px-6 py-3 bg-accent text-white rounded-lg text-base font-medium hover:bg-accent-hover">Large</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
{activeTab === 'cards' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">카드</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-2">기본 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">카드 본문 내용이 들어갑니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5 hover:border-accent hover:shadow-md transition">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-2">호버 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">마우스를 올려보세요.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-soft border border-accent/20 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-accent mb-2">강조 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">accent-soft 배경을 사용합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl overflow-hidden">
|
||||||
|
<div className="px-5 py-3 bg-bg-tertiary border-b border-border-default">
|
||||||
|
<h3 className="font-semibold text-text-primary text-sm">헤더 카드</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<p className="text-sm text-text-secondary">헤더가 있는 카드입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-2">기본 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">카드 본문 내용</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-accent-soft border border-accent/20 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-accent mb-2">강조 카드</h3>
|
||||||
|
<p className="text-sm text-text-secondary">accent-soft 배경</p>
|
||||||
|
</div>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 알림 */}
|
||||||
|
{activeTab === 'alerts' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">알림</h2>
|
||||||
|
<Alert type="info" title="정보">이것은 정보 알림입니다.</Alert>
|
||||||
|
<Alert type="success" title="성공">작업이 성공적으로 완료되었습니다.</Alert>
|
||||||
|
<Alert type="warning" title="주의">이 작업은 되돌릴 수 없습니다.</Alert>
|
||||||
|
<Alert type="error" title="에러">요청을 처리하는 중 오류가 발생했습니다.</Alert>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`import { Alert } from '../components/common/Alert';
|
||||||
|
|
||||||
|
<Alert type="info" title="정보">이것은 정보 알림입니다.</Alert>
|
||||||
|
<Alert type="success" title="성공">작업이 성공적으로 완료되었습니다.</Alert>
|
||||||
|
<Alert type="warning" title="주의">이 작업은 되돌릴 수 없습니다.</Alert>
|
||||||
|
<Alert type="error" title="에러">요청 처리 중 오류가 발생했습니다.</Alert>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 배지 */}
|
||||||
|
{activeTab === 'badges' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">배지</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="px-2.5 py-0.5 bg-accent/10 text-accent rounded-full text-xs font-medium">기본</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-success/10 text-success rounded-full text-xs font-medium">성공</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-warning/10 text-warning rounded-full text-xs font-medium">주의</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-danger/10 text-danger rounded-full text-xs font-medium">위험</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-info/10 text-info rounded-full text-xs font-medium">정보</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-bg-tertiary text-text-muted rounded-full text-xs font-medium">비활성</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<span className="px-2.5 py-0.5 bg-accent/10 text-accent rounded-full text-xs font-medium">
|
||||||
|
기본
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-0.5 bg-success/10 text-success rounded-full text-xs font-medium">
|
||||||
|
성공
|
||||||
|
</span>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
{activeTab === 'forms' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">폼 요소</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 space-y-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">텍스트 입력</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="입력하세요..."
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">셀렉트</label>
|
||||||
|
<select className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent">
|
||||||
|
<option>옵션 1</option>
|
||||||
|
<option>옵션 2</option>
|
||||||
|
<option>옵션 3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">텍스트영역</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="내용을 입력하세요..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" className="rounded accent-accent" defaultChecked />
|
||||||
|
<span className="text-sm text-text-primary">체크박스</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="demo" className="accent-accent" defaultChecked />
|
||||||
|
<span className="text-sm text-text-primary">라디오 A</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="demo" className="accent-accent" />
|
||||||
|
<span className="text-sm text-text-primary">라디오 B</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<input
|
||||||
|
type="text"
|
||||||
|
placeholder="입력하세요..."
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm
|
||||||
|
bg-bg-primary text-text-primary
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{activeTab === 'tables' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">테이블</h2>
|
||||||
|
<div className="overflow-x-auto mb-4">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">이름</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">이메일</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">홍길동</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">hong@gcsc.co.kr</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 bg-success/10 text-success rounded-full text-xs font-medium">활성</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button className="text-link hover:text-accent-hover text-sm">편집</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">김철수</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">kim@gcsc.co.kr</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 bg-warning/10 text-warning rounded-full text-xs font-medium">대기</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button className="text-link hover:text-accent-hover text-sm">편집</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">헤더</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">데이터</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테마 커스텀 */}
|
||||||
|
{activeTab === 'theme' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">커스텀 테마 구성</h2>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">테마 시스템 구조</h3>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
이 프로젝트는 CSS 변수 기반 테마 시스템을 사용합니다.
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">data-theme</code> 속성으로 테마를 전환하고,
|
||||||
|
시맨틱 변수를 통해 일관된 색상을 유지합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="css"
|
||||||
|
filename="index.css — 구조"
|
||||||
|
code={`/* 1. Tailwind에 시맨틱 색상 등록 */
|
||||||
|
@theme {
|
||||||
|
--color-bg-primary: var(--theme-bg-primary);
|
||||||
|
--color-accent: var(--theme-accent);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 테마별 실제 색상값 정의 */
|
||||||
|
:root, [data-theme="light"] {
|
||||||
|
--theme-bg-primary: #f8f9fa;
|
||||||
|
--theme-accent: #213079;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--theme-bg-primary: #011C2F;
|
||||||
|
--theme-accent: #02908B;
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-8 mb-3">새 테마 추가하기</h3>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
새로운 테마를 추가하려면 CSS에 <code className="bg-bg-tertiary px-1 rounded">[data-theme="이름"]</code> 블록을 정의하면 됩니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="css"
|
||||||
|
filename="index.css — 커스텀 테마 추가 예시"
|
||||||
|
code={`/* Ocean 테마 */
|
||||||
|
[data-theme="ocean"] {
|
||||||
|
--theme-bg-primary: #0a192f;
|
||||||
|
--theme-bg-secondary: #112240;
|
||||||
|
--theme-bg-tertiary: #1d3557;
|
||||||
|
--theme-text-primary: #ccd6f6;
|
||||||
|
--theme-text-secondary: #8892b0;
|
||||||
|
--theme-text-muted: #495670;
|
||||||
|
--theme-border-default: #233554;
|
||||||
|
--theme-border-subtle: rgba(255, 255, 255, 0.08);
|
||||||
|
--theme-accent: #64ffda;
|
||||||
|
--theme-accent-hover: #4fd1b5;
|
||||||
|
--theme-accent-soft: rgba(100, 255, 218, 0.1);
|
||||||
|
--theme-surface: #112240;
|
||||||
|
--theme-sidebar-bg: #0a192f;
|
||||||
|
--theme-sidebar-active-bg: #64ffda;
|
||||||
|
--theme-sidebar-active-text: #0a192f;
|
||||||
|
--theme-sidebar-text: #8892b0;
|
||||||
|
--theme-info: #57cbff;
|
||||||
|
--theme-success: #64ffda;
|
||||||
|
--theme-warning: #ffd166;
|
||||||
|
--theme-danger: #ff6b6b;
|
||||||
|
--theme-link: #64ffda;
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-8 mb-3">시맨틱 색상 변수 레퍼런스</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">변수</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">Tailwind 클래스</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">용도</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{[
|
||||||
|
{ var: '--theme-bg-primary', cls: 'bg-bg-primary', use: '페이지 배경' },
|
||||||
|
{ var: '--theme-bg-secondary', cls: 'bg-bg-secondary', use: '보조 배경' },
|
||||||
|
{ var: '--theme-bg-tertiary', cls: 'bg-bg-tertiary', use: 'UI 요소 배경' },
|
||||||
|
{ var: '--theme-text-primary', cls: 'text-text-primary', use: '주 텍스트' },
|
||||||
|
{ var: '--theme-text-secondary', cls: 'text-text-secondary', use: '보조 텍스트' },
|
||||||
|
{ var: '--theme-text-muted', cls: 'text-text-muted', use: '비활성 텍스트' },
|
||||||
|
{ var: '--theme-accent', cls: 'bg-accent / text-accent', use: '주 강조색' },
|
||||||
|
{ var: '--theme-accent-hover', cls: 'hover:bg-accent-hover', use: '호버 강조색' },
|
||||||
|
{ var: '--theme-accent-soft', cls: 'bg-accent-soft', use: '연한 강조 배경' },
|
||||||
|
{ var: '--theme-surface', cls: 'bg-surface', use: '카드/패널 배경' },
|
||||||
|
{ var: '--theme-border-default', cls: 'border-border-default', use: '기본 테두리' },
|
||||||
|
{ var: '--theme-link', cls: 'text-link', use: '링크 텍스트' },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.var}>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs text-accent">{row.var}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs text-text-secondary">{row.cls}</td>
|
||||||
|
<td className="px-4 py-2 text-text-secondary">{row.use}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" title="테마 전환 확인">
|
||||||
|
사이드바 하단의 테마 토글 버튼으로 라이트/다크/시스템 모드를 전환하면
|
||||||
|
이 페이지의 모든 컴포넌트가 실시간으로 반영됩니다.
|
||||||
|
</Alert>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 향후 확장 */}
|
||||||
|
{activeTab === 'future' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-4">향후 확장 계획</h2>
|
||||||
|
|
||||||
|
<Alert type="info">
|
||||||
|
현재는 프로젝트 내부 네이티브 구현이며, 향후 공통 UI 컴포넌트 프로젝트로 분리할 계획입니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">공통 UI 컴포넌트 프로젝트</h3>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5 mb-4">
|
||||||
|
<div className="space-y-3 text-sm text-text-secondary">
|
||||||
|
<p>
|
||||||
|
<strong className="text-text-primary">목표:</strong> 프론트엔드 퍼블리셔가 담당하는 별도 프로젝트로 공통 UI 컴포넌트를 관리
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-text-primary">배포:</strong> 컴포넌트별 시맨틱 버저닝으로 Nexus npm 레지스트리에 퍼블리시
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-text-primary">사용:</strong> 프로젝트 템플릿에서 이름+버전으로 import하여 사용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">컴포넌트 네이밍/버저닝 규칙</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
filename="package.json 예시"
|
||||||
|
code={`{
|
||||||
|
"name": "@gc/ui-components",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"exports": {
|
||||||
|
"./Button": "./dist/Button.js",
|
||||||
|
"./Card": "./dist/Card.js",
|
||||||
|
"./Alert": "./dist/Alert.js",
|
||||||
|
"./Table": "./dist/Table.js"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">프로젝트에서 사용</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="tsx"
|
||||||
|
code={`import { Button } from '@gc/ui-components/Button';
|
||||||
|
import { Card } from '@gc/ui-components/Card';
|
||||||
|
import { Alert } from '@gc/ui-components/Alert';
|
||||||
|
|
||||||
|
function MyPage() {
|
||||||
|
return (
|
||||||
|
<Card title="사용자 목록">
|
||||||
|
<Alert type="info">로딩 중입니다.</Alert>
|
||||||
|
<Button variant="primary" onClick={handleSubmit}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">자동 문서 연동</h3>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
공통 컴포넌트 프로젝트가 완성되면 gc-guide에서 레지스트리 정보를 읽어
|
||||||
|
각 컴포넌트별 버전별 퍼블리시 문서를 자동으로 생성/연동할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="font-mono text-sm space-y-1.5 text-text-secondary">
|
||||||
|
<div><span className="text-accent">@gc/ui-components</span></div>
|
||||||
|
<div className="pl-4">v1.2.0 (latest) — Button, Card, Alert, Table, Badge</div>
|
||||||
|
<div className="pl-4">v1.1.0 — Button, Card, Alert, Table</div>
|
||||||
|
<div className="pl-4">v1.0.0 — Button, Card, Alert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/content/DevEnvIntro.tsx
Normal file
116
src/content/DevEnvIntro.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
|
||||||
|
export default function DevEnvIntro() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">개발환경 소개</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
GC SI 개발팀이 사용하는 인프라 구성과 핵심 서비스를 소개합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 인프라 구성도 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">인프라 구성</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="bg-accent-soft rounded-lg p-4">
|
||||||
|
<div className="text-2xl mb-2">🖥️</div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">개발자 로컬</h4>
|
||||||
|
<p className="text-xs text-text-muted mt-1">IDE + Git + SDK</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-soft rounded-lg p-4">
|
||||||
|
<div className="text-2xl mb-2">🔄</div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">CI/CD</h4>
|
||||||
|
<p className="text-xs text-text-muted mt-1">Gitea Actions</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-soft rounded-lg p-4">
|
||||||
|
<div className="text-2xl mb-2">🚀</div>
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm">운영 서버</h4>
|
||||||
|
<p className="text-xs text-text-muted mt-1">Docker + Nginx</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center mt-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
|
<span>Push</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span>Build & Test</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span>Deploy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 핵심 서비스 카드 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">핵심 서비스</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="w-10 h-10 bg-accent-soft rounded-lg flex items-center justify-center mb-3">
|
||||||
|
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-text-primary mb-1">Gitea</h3>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
소스 코드 관리, 이슈 트래킹, MR 기반 코드 리뷰
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="w-10 h-10 bg-accent-soft rounded-lg flex items-center justify-center mb-3">
|
||||||
|
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-text-primary mb-1">Nexus</h3>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Maven, npm 프록시 저장소 및 프라이빗 패키지 배포
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="w-10 h-10 bg-accent-soft rounded-lg flex items-center justify-center mb-3">
|
||||||
|
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-text-primary mb-1">SI DevBot</h3>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Google Chat 연동 Gitea 알림
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도메인 테이블 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">서비스 도메인</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">서비스</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">URL</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">용도</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">Gitea</td>
|
||||||
|
<td className="px-4 py-3"><a href="https://gitea.gc-si.dev" target="_blank" rel="noopener noreferrer" className="text-link hover:underline">gitea.gc-si.dev</a></td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">Git 저장소</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">Nexus</td>
|
||||||
|
<td className="px-4 py-3"><a href="https://nexus.gc-si.dev" target="_blank" rel="noopener noreferrer" className="text-link hover:underline">nexus.gc-si.dev</a></td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">패키지 저장소</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">가이드</td>
|
||||||
|
<td className="px-4 py-3"><a href="https://guide.gc-si.dev" target="_blank" rel="noopener noreferrer" className="text-link hover:underline">guide.gc-si.dev</a></td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">개발자 가이드 (본 사이트)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" title="접속 안내">
|
||||||
|
모든 서비스는 <strong>Google OAuth (@gcsc.co.kr)</strong>로 인증합니다. 사내 Google Workspace 계정이 필요합니다.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/content/GitWorkflow.tsx
Normal file
190
src/content/GitWorkflow.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function GitWorkflow() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Git 워크플로우</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
팀 브랜치 전략, 커밋 규칙, 3계층 보호 정책을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 브랜치 전략 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">브랜치 전략</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6 mb-6">
|
||||||
|
<div className="font-mono text-sm space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="inline-block w-24 px-2 py-1 bg-danger/10 text-danger rounded text-center font-semibold">main</span>
|
||||||
|
<span className="text-text-secondary">← 배포 가능한 안정 브랜치 (보호됨)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-6">
|
||||||
|
<span className="text-text-muted">└──</span>
|
||||||
|
<span className="inline-block w-24 px-2 py-1 bg-info/10 text-info rounded text-center font-semibold">develop</span>
|
||||||
|
<span className="text-text-secondary">← 개발 통합 브랜치</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-16">
|
||||||
|
<span className="text-text-muted">├──</span>
|
||||||
|
<span className="text-success">feature/ISSUE-123-기능설명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-16">
|
||||||
|
<span className="text-text-muted">├──</span>
|
||||||
|
<span className="text-warning">bugfix/ISSUE-456-버그설명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pl-16">
|
||||||
|
<span className="text-text-muted">└──</span>
|
||||||
|
<span className="text-danger">hotfix/ISSUE-789-긴급수정</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="warning">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code> 브랜치에 직접 커밋/푸시는 금지됩니다. 반드시 MR을 통해 머지하세요.{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치는 push 가능하지만 서버 pre-receive hook 검증을 통과해야 합니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 브랜치 네이밍 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">브랜치 네이밍</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">유형</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">패턴</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">예시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">기능</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-text-secondary">feature/ISSUE-번호-설명</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-accent">feature/ISSUE-42-user-login</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">버그</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-text-secondary">bugfix/ISSUE-번호-설명</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-accent">bugfix/ISSUE-56-date-format</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-text-primary">긴급</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-text-secondary">hotfix/ISSUE-번호-설명</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm text-accent">hotfix/ISSUE-99-api-timeout</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conventional Commits */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">Conventional Commits</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
모든 커밋 메시지는 다음 형식을 따릅니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`type(scope): subject
|
||||||
|
|
||||||
|
body (선택)
|
||||||
|
|
||||||
|
footer (선택)`}
|
||||||
|
/>
|
||||||
|
<div className="overflow-x-auto mt-4">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">type</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">설명</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">예시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{[
|
||||||
|
{ type: 'feat', desc: '새로운 기능 추가', ex: 'feat(auth): JWT 기반 로그인 구현' },
|
||||||
|
{ type: 'fix', desc: '버그 수정', ex: 'fix(배치): 야간 배치 타임아웃 수정' },
|
||||||
|
{ type: 'refactor', desc: '리팩토링', ex: 'refactor(user): 중복 로직 추출' },
|
||||||
|
{ type: 'docs', desc: '문서 변경', ex: 'docs: README에 빌드 방법 추가' },
|
||||||
|
{ type: 'test', desc: '테스트 추가/수정', ex: 'test(결제): 환불 로직 단위 테스트' },
|
||||||
|
{ type: 'chore', desc: '빌드, 설정 변경', ex: 'chore: Gradle 의존성 업데이트' },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.type}>
|
||||||
|
<td className="px-4 py-3 font-mono text-accent font-medium">{row.type}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{row.desc}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">{row.ex}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 흐름 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">일반 작업 흐름</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: '이슈 확인 및 브랜치 생성',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b feature/ISSUE-42-user-login`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '개발 및 커밋',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# 작업 후 커밋
|
||||||
|
git add src/auth/LoginForm.tsx
|
||||||
|
git commit -m "feat(auth): 로그인 폼 UI 구현 (#42)"`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '푸시 및 MR 생성',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git push -u origin feature/ISSUE-42-user-login
|
||||||
|
# Gitea에서 MR 생성 (develop ← feature/ISSUE-42-user-login)`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '리뷰 후 머지',
|
||||||
|
content: (
|
||||||
|
<p>최소 1명의 리뷰어 승인 → <strong>Squash Merge</strong> → 소스 브랜치 삭제</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3계층 보호 정책 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">3계층 보호 정책</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-danger mb-1">1. Git Hooks (로컬)</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">commit-msg</code> 훅으로 커밋 메시지 형식을 검증합니다. Conventional Commits 규칙에 맞지 않으면 커밋이 거부됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-warning mb-1">2. 서버 Pre-receive Hook</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Push 시 서버에서 <code className="bg-bg-tertiary px-1 rounded">pre-receive hook</code>이 자동 실행됩니다.{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code>/<code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치 직접 push 차단,
|
||||||
|
커밋 메시지 형식 검증, 금지 파일(credentials, .env 등) 차단, force push 차단을 수행합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-success mb-1">3. 브랜치 보호 규칙</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code> 브랜치는 MR을 통해서만 머지 가능하며, 최소 1명의 리뷰어 승인이 필수입니다.{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치는 push가 허용되지만, pre-receive hook 검증을 통과해야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/content/GiteaUsage.tsx
Normal file
132
src/content/GiteaUsage.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function GiteaUsage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Gitea 사용법</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
팀 Git 저장소인 Gitea의 로그인, 리포지토리 관리, 이슈/MR 사용법을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 로그인 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">로그인</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'Gitea 접속',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
브라우저에서 <strong>gitea.gc-si.dev</strong>에 접속합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Google OAuth 로그인',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
<strong>"Sign in with OpenID Connect"</strong> 버튼을 클릭하여 @gcsc.co.kr Google 계정으로 로그인합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '최초 로그인 시 조직 확인',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
최초 로그인 시 <strong>gc</strong> 조직에 자동으로 소속됩니다. 대시보드에서 조직 리포지토리 목록을 확인하세요.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 리포지토리 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">리포지토리 클론</h2>
|
||||||
|
<p className="text-text-secondary mb-4">SSH 또는 HTTPS로 리포지토리를 클론할 수 있습니다.</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# SSH (권장 — SSH 키 등록 필요)
|
||||||
|
git clone git@gitea.gc-si.dev:gc/프로젝트명.git
|
||||||
|
|
||||||
|
# HTTPS
|
||||||
|
git clone https://gitea.gc-si.dev/gc/프로젝트명.git`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
SSH 키가 등록되어 있으면 별도 인증 없이 push/pull이 가능합니다. 초기 환경 설정 가이드에서 SSH 키를 먼저 등록하세요.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 이슈 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">이슈 관리</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
기능 요청, 버그 리포트, 작업 단위를 이슈로 관리합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: '이슈 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
리포지토리 → <strong>이슈</strong> → <strong>새 이슈</strong>를 클릭하고, 제목과 설명을 작성합니다. 라벨(<code className="bg-bg-tertiary px-1 rounded">feature</code>, <code className="bg-bg-tertiary px-1 rounded">bug</code> 등)을 지정하세요.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '브랜치와 연결',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">이슈 번호를 포함한 브랜치를 생성합니다.</p>
|
||||||
|
<CodeBlock language="bash" code="git checkout -b feature/ISSUE-42-user-login develop" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '커밋에 이슈 참조',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">커밋 메시지에 이슈 번호를 포함하면 자동으로 연결됩니다.</p>
|
||||||
|
<CodeBlock language="bash" code='git commit -m "feat(auth): 로그인 폼 구현 (#42)"' />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MR */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">MR (Merge Request)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
코드 리뷰와 머지를 위해 MR을 생성합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: '브랜치 푸시',
|
||||||
|
content: (
|
||||||
|
<CodeBlock language="bash" code="git push -u origin feature/ISSUE-42-user-login" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'MR 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Gitea에서 <strong>"새 Pull Request"</strong>를 클릭하고, <code className="bg-bg-tertiary px-1 rounded">develop</code> ← <code className="bg-bg-tertiary px-1 rounded">feature/ISSUE-42-user-login</code>으로 설정합니다. 제목은 커밋 규칙과 동일하게 작성하세요.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '리뷰 및 머지',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
리뷰어를 지정하고, 최소 1명의 승인 후 <strong>Squash Merge</strong>로 머지합니다. 머지 후 소스 브랜치는 삭제합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert type="warning" title="주의">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">main</code>, <code className="bg-bg-tertiary px-1 rounded">develop</code> 브랜치에는 직접 push가 금지되어 있습니다. 반드시 MR을 통해 머지하세요.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/content/InitialSetup.tsx
Normal file
166
src/content/InitialSetup.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function InitialSetup() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">초기 환경 설정</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
개발을 시작하기 전 필요한 도구와 설정을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* SSH 키 생성 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">1. SSH 키 생성 및 등록</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea에 SSH로 접근하려면 SSH 키가 필요합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'SSH 키 생성',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">터미널에서 다음 명령어를 실행합니다.</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`ssh-keygen -t ed25519 -C "your-email@gcsc.co.kr"
|
||||||
|
# Enter 키를 눌러 기본 경로에 저장
|
||||||
|
# 패스프레이즈는 선택사항`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '공개 키 복사',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<CodeBlock language="bash" code="cat ~/.ssh/id_ed25519.pub" />
|
||||||
|
<p className="mt-2">출력된 내용을 전체 복사합니다.</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gitea에 등록',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Gitea → <strong>설정</strong> → <strong>SSH / GPG 키</strong> → <strong>SSH 키 추가</strong>에서 복사한 공개 키를 붙여넣고 저장합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Git 설정 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">2. Git 기본 설정</h2>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
filename="~/.gitconfig"
|
||||||
|
code={`git config --global user.name "홍길동"
|
||||||
|
git config --global user.email "hong@gcsc.co.kr"
|
||||||
|
git config --global init.defaultBranch main
|
||||||
|
git config --global core.autocrlf input`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
이메일은 반드시 <strong>@gcsc.co.kr</strong> 도메인을 사용하세요. Gitea 커밋 연동에 필요합니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* SDKMAN */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">3. SDKMAN! (JDK 관리)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Java 프로젝트를 위해 SDKMAN!으로 JDK를 관리합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'SDKMAN! 설치',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`curl -s "https://get.sdkman.io" | bash
|
||||||
|
source "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||||
|
sdk version`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'JDK 설치',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`# Amazon Corretto 21 (기본)
|
||||||
|
sdk install java 21.0.9-amzn
|
||||||
|
|
||||||
|
# 프로젝트별 JDK 17이 필요한 경우
|
||||||
|
sdk install java 17.0.18-amzn
|
||||||
|
sdk use java 17.0.18-amzn`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
프로젝트 루트에 <code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code> 파일이 있으면 해당 버전이 자동 선택됩니다.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Maven 설치',
|
||||||
|
content: (
|
||||||
|
<CodeBlock language="bash" code="sdk install maven 3.9.12" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* fnm */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">4. fnm (Node.js 관리)</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
프론트엔드 프로젝트를 위해 fnm으로 Node.js를 관리합니다.
|
||||||
|
</p>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'fnm 설치',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`curl -fsSL https://fnm.vercel.app/install | bash
|
||||||
|
# 셸 재시작 후
|
||||||
|
fnm --version`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Node.js 설치',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`fnm install 24
|
||||||
|
fnm default 24
|
||||||
|
node --version`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
프로젝트 루트에 <code className="bg-bg-tertiary px-1 rounded">.node-version</code> 파일이 있으면 <code className="bg-bg-tertiary px-1 rounded">fnm use</code>로 자동 전환됩니다.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Claude Code */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">5. Claude Code 설치</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
AI 기반 코딩 어시스턴트로 개발 생산성을 높입니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`npm install -g @anthropic-ai/claude-code
|
||||||
|
claude --version`}
|
||||||
|
/>
|
||||||
|
<Alert type="warning" title="API 키 필요">
|
||||||
|
Claude Code 사용을 위해 팀 관리자에게 API 키를 요청하세요.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/content/NexusUsage.tsx
Normal file
116
src/content/NexusUsage.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
|
||||||
|
export default function NexusUsage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">Nexus 사용법</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
Maven, Gradle, npm 프록시 설정 방법과 프라이빗 패키지 배포 가이드입니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Alert type="info" title="Nexus 주소">
|
||||||
|
<strong>nexus.gc-si.dev</strong> — 웹 UI에서 저장소 목록과 패키지를 확인할 수 있습니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Maven */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">Maven 프록시 설정</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Maven 프로젝트에서 Nexus를 프록시로 사용하려면 <code className="bg-bg-tertiary px-1 rounded">~/.m2/settings.xml</code>을 설정합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="xml"
|
||||||
|
filename="~/.m2/settings.xml"
|
||||||
|
code={`<settings>
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>nexus</id>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>nexus</id>
|
||||||
|
<username>\${env.NEXUS_USERNAME}</username>
|
||||||
|
<password>\${env.NEXUS_PASSWORD}</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
</settings>`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gradle */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">Gradle 프록시 설정</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">build.gradle</code>의 repositories 블록에 Nexus를 추가합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="groovy"
|
||||||
|
filename="build.gradle"
|
||||||
|
code={`repositories {
|
||||||
|
maven {
|
||||||
|
url 'https://nexus.gc-si.dev/repository/maven-public/'
|
||||||
|
credentials {
|
||||||
|
username = findProperty('nexusUsername') ?: System.getenv('NEXUS_USERNAME')
|
||||||
|
password = findProperty('nexusPassword') ?: System.getenv('NEXUS_PASSWORD')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Alert type="info">
|
||||||
|
인증 정보는 <code className="bg-bg-tertiary px-1 rounded">~/.gradle/gradle.properties</code>에 <code className="bg-bg-tertiary px-1 rounded">nexusUsername</code>/<code className="bg-bg-tertiary px-1 rounded">nexusPassword</code>로 설정할 수도 있습니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* npm */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">npm 프록시 설정</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
프로젝트 루트의 <code className="bg-bg-tertiary px-1 rounded">.npmrc</code> 파일에 Nexus 레지스트리를 설정합니다.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="ini"
|
||||||
|
filename=".npmrc"
|
||||||
|
code={`registry=https://nexus.gc-si.dev/repository/npm-public/
|
||||||
|
//nexus.gc-si.dev/repository/npm-public/:_auth=\${NPM_AUTH_TOKEN}
|
||||||
|
always-auth=true`}
|
||||||
|
/>
|
||||||
|
<Alert type="warning" title="보안 주의">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">_auth</code> 값을 <code className="bg-bg-tertiary px-1 rounded">.npmrc</code>에 직접 하드코딩하지 마세요. 환경변수 또는 <code className="bg-bg-tertiary px-1 rounded">~/.npmrc</code>(글로벌)에 설정하고, 프로젝트 <code className="bg-bg-tertiary px-1 rounded">.npmrc</code>는 레지스트리 URL만 포함합니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 패키지 배포 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">프라이빗 패키지 배포</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
사내 공유 라이브러리를 Nexus에 배포할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">Maven 배포</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="xml"
|
||||||
|
filename="pom.xml"
|
||||||
|
code={`<distributionManagement>
|
||||||
|
<repository>
|
||||||
|
<id>nexus</id>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-releases/</url>
|
||||||
|
</repository>
|
||||||
|
<snapshotRepository>
|
||||||
|
<id>nexus</id>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-snapshots/</url>
|
||||||
|
</snapshotRepository>
|
||||||
|
</distributionManagement>`}
|
||||||
|
/>
|
||||||
|
<CodeBlock language="bash" code="mvn deploy" />
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mt-6 mb-3">npm 배포</h3>
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
filename="package.json"
|
||||||
|
code={`{
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://nexus.gc-si.dev/repository/npm-hosted/"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<CodeBlock language="bash" code="npm publish" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
src/content/StartingProject.tsx
Normal file
227
src/content/StartingProject.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { Alert } from '../components/common/Alert';
|
||||||
|
import { CodeBlock } from '../components/common/CodeBlock';
|
||||||
|
import { StepGuide } from '../components/common/StepGuide';
|
||||||
|
|
||||||
|
export default function StartingProject() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-text-primary mb-2">프로젝트 시작하기</h1>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
팀 템플릿을 사용해 새 프로젝트를 빠르게 시작하는 방법을 안내합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 템플릿 비교 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">프로젝트 템플릿</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
Gitea <code className="bg-bg-tertiary px-1 rounded">gc</code> 조직에서 프로젝트 유형에 맞는 템플릿을 선택합니다.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">템플릿</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">기술 스택</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">포함 내용</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-java-maven</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">Java + Spring Boot + Maven</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.mvn/settings.xml</code>,{' '}
|
||||||
|
Claude 규칙/스킬, Git hooks
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-java-gradle</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">Java + Spring Boot + Gradle</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">gradle.properties.example</code>,{' '}
|
||||||
|
Claude 규칙/스킬, Git hooks
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-react-ts</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">React + TypeScript + Vite</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.node-version</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.npmrc</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.prettierrc</code>,{' '}
|
||||||
|
Claude 규칙/스킬, Git hooks
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium text-text-primary">template-common</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">공통 워크플로우</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
팀 규칙, Claude 스킬, Git hooks, 버전 관리 (프로젝트 템플릿이 아닌 규칙 원본)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info">
|
||||||
|
모든 프로젝트 템플릿에는 <code className="bg-bg-tertiary px-1 rounded">.claude/</code> 디렉토리(규칙, 스킬, 설정),{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.githooks/</code>(commit-msg, post-checkout),{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">.editorconfig</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">CLAUDE.md</code>가 공통으로 포함되어 있습니다.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 새 프로젝트 생성 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">새 프로젝트 생성</h2>
|
||||||
|
<StepGuide
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: 'Gitea에서 리포지토리 생성',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Gitea → <strong>gc</strong> 조직 → <strong>새 저장소</strong>를 클릭합니다.
|
||||||
|
<strong> "템플릿에서 생성"</strong>에서 프로젝트 유형에 맞는 템플릿(<code className="bg-bg-tertiary px-1 rounded">template-java-maven</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">template-java-gradle</code>,{' '}
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded">template-react-ts</code>)을 선택하고 프로젝트 이름을 입력합니다.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '로컬에 클론',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git clone git@gitea.gc-si.dev:gc/새-프로젝트명.git
|
||||||
|
cd 새-프로젝트명`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Claude Code로 초기화',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">Claude Code 세션에서 프로젝트 초기화 스킬을 실행합니다.</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`claude
|
||||||
|
# 세션 내에서 실행:
|
||||||
|
/init-project`}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-text-muted text-xs">
|
||||||
|
이 명령은 팀 워크플로우 규칙, Git hooks, Claude 설정 파일을 자동으로 구성합니다.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'develop 브랜치 생성',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git checkout -b develop
|
||||||
|
git push -u origin develop`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '첫 feature 브랜치 시작',
|
||||||
|
content: (
|
||||||
|
<CodeBlock
|
||||||
|
language="bash"
|
||||||
|
code={`git checkout -b feature/ISSUE-1-project-setup
|
||||||
|
# 코딩 시작!`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 템플릿 공통 파일 구조 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">템플릿 공통 파일 구조</h2>
|
||||||
|
<p className="text-text-secondary mb-4">
|
||||||
|
모든 프로젝트 템플릿에 포함되는 공통 파일입니다.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="font-mono text-sm space-y-1.5 text-text-secondary">
|
||||||
|
<div><span className="text-accent">.claude/</span></div>
|
||||||
|
<div className="pl-4"><span className="text-accent">rules/</span> — 팀 규칙 (code-style, git-workflow, naming, testing, team-policy)</div>
|
||||||
|
<div className="pl-4"><span className="text-accent">skills/</span> — Claude 스킬 (create-mr, fix-issue, init-project, sync-team-workflow)</div>
|
||||||
|
<div className="pl-4"><span className="text-accent">settings.json</span> — Claude 권한 설정</div>
|
||||||
|
<div className="mt-2"><span className="text-accent">.githooks/</span></div>
|
||||||
|
<div className="pl-4"><span className="text-accent">commit-msg</span> — Conventional Commits 검증 훅</div>
|
||||||
|
<div className="pl-4"><span className="text-accent">post-checkout</span> — 체크아웃 후 자동 실행</div>
|
||||||
|
<div className="mt-2"><span className="text-accent">.editorconfig</span> — 에디터 공통 설정</div>
|
||||||
|
<div><span className="text-accent">.gitignore</span> — Git 제외 패턴</div>
|
||||||
|
<div><span className="text-accent">CLAUDE.md</span> — 프로젝트 설명서</div>
|
||||||
|
<div><span className="text-accent">workflow-version.json</span> — 워크플로우 버전 추적</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 템플릿별 추가 파일 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">템플릿별 추가 파일</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-2">template-java-maven</h4>
|
||||||
|
<div className="font-mono text-xs space-y-1 text-text-secondary">
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code> — JDK 버전</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.mvn/settings.xml</code> — Maven 설정</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-2">template-java-gradle</h4>
|
||||||
|
<div className="font-mono text-xs space-y-1 text-text-secondary">
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.sdkmanrc</code> — JDK 버전</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">gradle.properties.example</code> — Gradle 설정 예시</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-text-primary text-sm mb-2">template-react-ts</h4>
|
||||||
|
<div className="font-mono text-xs space-y-1 text-text-secondary">
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.node-version</code> — Node.js 버전</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.npmrc</code> — npm 레지스트리</div>
|
||||||
|
<div><code className="bg-bg-tertiary px-1 rounded">.prettierrc</code> — 코드 포매터</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" title="워크플로우 업데이트">
|
||||||
|
팀 워크플로우가 업데이트되면 세션 시작 시 알림이 표시됩니다.
|
||||||
|
<code className="bg-bg-tertiary px-1 rounded ml-1">/sync-team-workflow</code>를 실행하여 최신 규칙을 동기화하세요.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 프로젝트 구조 권장안 */}
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mt-10 mb-4">권장 프로젝트 구조</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-text-primary mb-2">Spring Boot (Maven/Gradle)</h4>
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`src/main/java/com/gcsc/프로젝트/
|
||||||
|
├── controller/
|
||||||
|
├── service/
|
||||||
|
├── repository/
|
||||||
|
├── dto/
|
||||||
|
├── entity/
|
||||||
|
├── config/
|
||||||
|
└── common/`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-text-primary mb-2">React + TypeScript</h4>
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`src/
|
||||||
|
├── components/
|
||||||
|
│ ├── common/
|
||||||
|
│ └── layout/
|
||||||
|
├── pages/
|
||||||
|
├── hooks/
|
||||||
|
├── services/
|
||||||
|
├── types/
|
||||||
|
└── utils/`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/hooks/ThemeContext.ts
Normal file
15
src/hooks/ThemeContext.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
theme: 'system',
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
setTheme: () => {},
|
||||||
|
});
|
||||||
62
src/hooks/ThemeProvider.tsx
Normal file
62
src/hooks/ThemeProvider.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { ThemeContext } from './ThemeContext';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'gc-guide-theme';
|
||||||
|
|
||||||
|
function getSystemTheme(): 'light' | 'dark' {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(
|
||||||
|
getSystemTheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
mediaQuery.addEventListener('change', handler);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTheme = useCallback((newTheme: Theme) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||||
|
setThemeState(newTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ theme, resolvedTheme, setTheme }),
|
||||||
|
[theme, resolvedTheme, setTheme],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/hooks/useScrollSpy.ts
Normal file
32
src/hooks/useScrollSpy.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useScrollSpy(selectors: string = 'h2, h3') {
|
||||||
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const elements = document.querySelectorAll(selectors);
|
||||||
|
if (elements.length === 0) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting && entry.target.id) {
|
||||||
|
setActiveId(entry.target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '-80px 0px -60% 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.forEach((el) => {
|
||||||
|
if (el.id) observer.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [selectors]);
|
||||||
|
|
||||||
|
return activeId;
|
||||||
|
}
|
||||||
6
src/hooks/useTheme.ts
Normal file
6
src/hooks/useTheme.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext } from './ThemeContext';
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
106
src/index.css
Normal file
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
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
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
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
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
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
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/pages/admin/PermissionManagement.tsx
Normal file
171
src/pages/admin/PermissionManagement.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { Permission, Role } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
export function PermissionManagement() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
||||||
|
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||||
|
const [newPattern, setNewPattern] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchRoles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<Role[]>('/admin/roles');
|
||||||
|
setRoles(data);
|
||||||
|
if (data.length > 0 && !selectedRoleId) {
|
||||||
|
setSelectedRoleId(data[0].id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 빈 배열 유지
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedRoleId]);
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
if (!selectedRoleId) return;
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ urlPatterns: Permission[] }>(`/admin/roles/${selectedRoleId}/permissions`);
|
||||||
|
setPermissions(Array.isArray(data.urlPatterns) ? data.urlPatterns : []);
|
||||||
|
} catch {
|
||||||
|
setPermissions([]);
|
||||||
|
}
|
||||||
|
}, [selectedRoleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
}, [fetchRoles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selectedRoleId || !newPattern.trim()) return;
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/roles/${selectedRoleId}/permissions`, { urlPattern: newPattern.trim() });
|
||||||
|
setNewPattern('');
|
||||||
|
fetchPermissions();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (permissionId: number) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/permissions/${permissionId}`);
|
||||||
|
fetchPermissions();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-6">권한 관리</h1>
|
||||||
|
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-text-muted">
|
||||||
|
먼저 롤을 생성하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* 롤 목록 */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">롤 선택</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role.id}
|
||||||
|
onClick={() => setSelectedRoleId(role.id)}
|
||||||
|
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||||
|
selectedRoleId === role.id
|
||||||
|
? 'bg-accent text-white font-medium'
|
||||||
|
: 'text-text-secondary hover:bg-bg-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL 패턴 관리 */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl p-6">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-4">
|
||||||
|
{roles.find((r) => r.id === selectedRoleId)?.name} — URL 패턴
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 추가 폼 */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPattern}
|
||||||
|
onChange={(e) => setNewPattern(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
|
className="flex-1 px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
placeholder="/dev/** 또는 /admin/users"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newPattern.trim()}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 패턴 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{permissions.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted py-4 text-center">
|
||||||
|
등록된 URL 패턴이 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
permissions.map((perm) => (
|
||||||
|
<div
|
||||||
|
key={perm.id}
|
||||||
|
className="flex items-center justify-between px-3 py-2 bg-bg-primary rounded-lg"
|
||||||
|
>
|
||||||
|
<code className="text-sm font-mono text-text-primary">{perm.urlPattern}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(perm.id)}
|
||||||
|
className="text-text-muted hover:text-danger cursor-pointer p-1"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ant 패턴 가이드 */}
|
||||||
|
<div className="mt-6 p-4 bg-bg-tertiary rounded-lg">
|
||||||
|
<h4 className="text-sm font-semibold text-text-primary mb-2">패턴 문법</h4>
|
||||||
|
<div className="text-xs text-text-secondary space-y-1">
|
||||||
|
<p><code className="font-mono text-accent">/**</code> — 모든 경로</p>
|
||||||
|
<p><code className="font-mono text-accent">/dev/**</code> — /dev/ 하위 모든 경로</p>
|
||||||
|
<p><code className="font-mono text-accent">/admin/users</code> — 정확히 일치</p>
|
||||||
|
<p><code className="font-mono text-accent">/api/*/list</code> — 단일 세그먼트 와일드카드</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/pages/admin/RoleManagement.tsx
Normal file
187
src/pages/admin/RoleManagement.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { Role } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
interface RoleForm {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleManagement() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||||
|
const [form, setForm] = useState<RoleForm>({ name: '', description: '' });
|
||||||
|
|
||||||
|
const fetchRoles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<Role[]>('/admin/roles');
|
||||||
|
setRoles(data);
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 빈 배열 유지
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
}, [fetchRoles]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setForm({ name: '', description: '' });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (role: Role) => {
|
||||||
|
setEditingRole(role);
|
||||||
|
setForm({ name: role.name, description: role.description });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
if (editingRole) {
|
||||||
|
await api.put<Role>(`/admin/roles/${editingRole.id}`, form);
|
||||||
|
} else {
|
||||||
|
await api.post<Role>('/admin/roles', form);
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchRoles();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (roleId: number) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/roles/${roleId}`);
|
||||||
|
fetchRoles();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary">롤 관리</h1>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover cursor-pointer"
|
||||||
|
>
|
||||||
|
+ 새 롤
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<div className="col-span-full text-center py-12 text-text-muted">
|
||||||
|
등록된 롤이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
roles.map((role) => (
|
||||||
|
<div key={role.id} className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<h3 className="font-semibold text-text-primary">{role.name}</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(role)}
|
||||||
|
className="p-1.5 text-text-muted hover:text-accent cursor-pointer"
|
||||||
|
title="편집"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(role.id)}
|
||||||
|
className="p-1.5 text-text-muted hover:text-danger cursor-pointer"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-secondary mb-3">{role.description}</p>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-text-muted mb-1">URL 패턴</p>
|
||||||
|
{role.urlPatterns.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{role.urlPatterns.map((p) => (
|
||||||
|
<span key={p} className="px-2 py-0.5 bg-bg-tertiary rounded text-xs font-mono text-text-secondary">
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-text-muted">없음</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 생성/편집 모달 */}
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-surface rounded-xl shadow-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold text-text-primary mb-4">
|
||||||
|
{editingRole ? '롤 편집' : '새 롤 생성'}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">이름</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
placeholder="예: DEVELOPER"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">설명</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm bg-bg-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-accent resize-none"
|
||||||
|
rows={3}
|
||||||
|
placeholder="롤에 대한 설명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-bg-tertiary cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name.trim()}
|
||||||
|
className="px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{editingRole ? '수정' : '생성'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/pages/admin/StatsPage.tsx
Normal file
106
src/pages/admin/StatsPage.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { PageStat, StatsResponse } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
export function StatsPage() {
|
||||||
|
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||||
|
const [pageStats] = useState<PageStat[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<StatsResponse>('/admin/stats');
|
||||||
|
setStats(data);
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 기본값
|
||||||
|
setStats({
|
||||||
|
totalUsers: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
pendingUsers: 0,
|
||||||
|
totalPages: 7,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: '전체 사용자', value: stats?.totalUsers ?? 0, color: 'text-accent' },
|
||||||
|
{ label: '활성 사용자', value: stats?.activeUsers ?? 0, color: 'text-success' },
|
||||||
|
{ label: '승인 대기', value: stats?.pendingUsers ?? 0, color: 'text-warning' },
|
||||||
|
{ label: '가이드 페이지', value: stats?.totalPages ?? 0, color: 'text-info' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-6">통계</h1>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="bg-surface border border-border-default rounded-xl p-5">
|
||||||
|
<p className="text-sm text-text-muted mb-1">{card.label}</p>
|
||||||
|
<p className={`text-3xl font-bold ${card.color}`}>{card.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인기 페이지 */}
|
||||||
|
<h2 className="text-lg font-bold text-text-primary mb-4">인기 페이지</h2>
|
||||||
|
<div className="bg-surface border border-border-default rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">페이지</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">조회수</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">비율</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{pageStats.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-4 py-8 text-center text-text-muted">
|
||||||
|
아직 통계 데이터가 없습니다. 백엔드 API 연동 후 데이터가 표시됩니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
pageStats.map((page) => {
|
||||||
|
const maxViews = Math.max(...pageStats.map((p) => p.viewCount), 1);
|
||||||
|
const percent = Math.round((page.viewCount / maxViews) * 100);
|
||||||
|
return (
|
||||||
|
<tr key={page.pagePath}>
|
||||||
|
<td className="px-4 py-3 font-mono text-text-primary">{page.pagePath}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">{page.viewCount}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent rounded-full"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted w-8 text-right">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/pages/admin/UserManagement.tsx
Normal file
248
src/pages/admin/UserManagement.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { Role, User } from '../../types';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
type FilterStatus = 'ALL' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED';
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [filter, setFilter] = useState<FilterStatus>('ALL');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [roleModalUser, setRoleModalUser] = useState<User | null>(null);
|
||||||
|
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [usersData, rolesData] = await Promise.all([
|
||||||
|
api.get<User[]>('/admin/users'),
|
||||||
|
api.get<Role[]>('/admin/roles'),
|
||||||
|
]);
|
||||||
|
setUsers(usersData);
|
||||||
|
setRoles(rolesData);
|
||||||
|
} catch {
|
||||||
|
// API 미연동 시 빈 배열 유지
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleAction = async (userId: number, action: 'approve' | 'reject' | 'disable') => {
|
||||||
|
try {
|
||||||
|
await api.put<User>(`/admin/users/${userId}/${action}`, {});
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleSave = async () => {
|
||||||
|
if (!roleModalUser) return;
|
||||||
|
try {
|
||||||
|
await api.put<User>(`/admin/users/${roleModalUser.id}/roles`, { roleIds: selectedRoleIds });
|
||||||
|
setRoleModalUser(null);
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
// 에러 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = filter === 'ALL' ? users : users.filter((u) => u.status === filter);
|
||||||
|
|
||||||
|
const statusBadge = (status: User['status']) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
ACTIVE: 'bg-success/10 text-success',
|
||||||
|
PENDING: 'bg-warning/10 text-warning',
|
||||||
|
REJECTED: 'bg-danger/10 text-danger',
|
||||||
|
DISABLED: 'bg-bg-tertiary text-text-muted',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ACTIVE: '활성', PENDING: '대기', REJECTED: '거절', DISABLED: '비활성',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||||
|
{labels[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary mb-6">사용자 관리</h1>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
{(['ALL', 'PENDING', 'ACTIVE', 'REJECTED', 'DISABLED'] as FilterStatus[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilter(s)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm cursor-pointer transition-colors ${
|
||||||
|
filter === s
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-surface border border-border-default text-text-secondary hover:bg-bg-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === 'ALL' ? '전체' : s === 'PENDING' ? '대기' : s === 'ACTIVE' ? '활성' : s === 'REJECTED' ? '거절' : '비활성'}
|
||||||
|
{s !== 'ALL' && (
|
||||||
|
<span className="ml-1 text-xs opacity-70">
|
||||||
|
({users.filter((u) => u.status === s).length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-tertiary">
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">사용자</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">롤</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">가입일</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-text-muted">
|
||||||
|
{users.length === 0 ? '등록된 사용자가 없습니다.' : '해당 상태의 사용자가 없습니다.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="w-8 h-8 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 bg-accent-soft rounded-full flex items-center justify-center text-xs font-medium text-accent">
|
||||||
|
{user.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-text-primary">{user.name}</p>
|
||||||
|
<p className="text-xs text-text-muted">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{statusBadge(user.status)}</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
{user.roles.length > 0
|
||||||
|
? user.roles.map((r) => r.name).join(', ')
|
||||||
|
: <span className="text-text-muted">미배정</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted text-xs">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('ko-KR')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{user.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(user.id, 'approve')}
|
||||||
|
className="px-2.5 py-1 bg-success/10 text-success rounded text-xs font-medium hover:bg-success/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
승인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(user.id, 'reject')}
|
||||||
|
className="px-2.5 py-1 bg-danger/10 text-danger rounded text-xs font-medium hover:bg-danger/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{user.status === 'ACTIVE' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(user.id, 'disable')}
|
||||||
|
className="px-2.5 py-1 bg-bg-tertiary text-text-muted rounded text-xs font-medium hover:bg-border-default cursor-pointer"
|
||||||
|
>
|
||||||
|
비활성화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRoleModalUser(user);
|
||||||
|
setSelectedRoleIds(user.roles.map((r) => r.id));
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 bg-accent-soft text-accent rounded text-xs font-medium hover:bg-accent/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
롤 배정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 롤 배정 모달 */}
|
||||||
|
{roleModalUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-surface rounded-xl shadow-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold text-text-primary mb-4">
|
||||||
|
롤 배정 — {roleModalUser.name}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<label key={role.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-bg-tertiary cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRoleIds.includes(role.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedRoleIds(
|
||||||
|
e.target.checked
|
||||||
|
? [...selectedRoleIds, role.id]
|
||||||
|
: selectedRoleIds.filter((id) => id !== role.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="rounded accent-accent"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-primary">{role.name}</p>
|
||||||
|
<p className="text-xs text-text-muted">{role.description}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{roles.length === 0 && (
|
||||||
|
<p className="text-sm text-text-muted py-4 text-center">등록된 롤이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleModalUser(null)}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-bg-tertiary cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRoleSave}
|
||||||
|
className="px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover cursor-pointer"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/types/index.ts
Normal file
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
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
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
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
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
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
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
불러오는 중...
Reference in New Issue
Block a user