From 456cfdddd937b0a4509308f3e301211567362854 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 14 Feb 2026 17:38:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테마 시스템: CSS 변수 + data-theme + Tailwind v4 시맨틱 색상 (다크모드 지원) - 공통 컴포넌트: CodeBlock, Alert, StepGuide, CopyButton, TableOfContents - 가이드 콘텐츠 8개 섹션 (React.lazy 동적 로딩, 실제 인프라 검증 완료) - 관리자 페이지 4개 (사용자/롤/권한/통계) - 레이아웃: 반응형 사이드바 + 테마 토글 + ScrollSpy 목차 - 인증: Google OAuth 로그인/세션복원/로그아웃 백엔드 API 연동 - 개발모드 mock 인증 (import.meta.env.DEV 전용) Co-Authored-By: Claude Opus 4.6 --- .claude/scripts/on-commit.sh | 14 + .claude/scripts/on-post-compact.sh | 23 + .claude/scripts/on-pre-compact.sh | 8 + .claude/settings.json | 37 ++ .claude/skills/init-project/SKILL.md | 186 +++++++- .claude/skills/sync-team-workflow/SKILL.md | 35 +- .claude/workflow-version.json | 2 +- .githooks/commit-msg | 4 +- CLAUDE.md | 51 ++- src/App.tsx | 51 ++- src/auth/AuthContext.ts | 19 + src/auth/AuthProvider.tsx | 79 ++-- src/auth/ProtectedRoute.tsx | 4 +- src/auth/useAuth.ts | 2 +- src/components/common/Alert.tsx | 51 +++ src/components/common/CodeBlock.tsx | 39 ++ src/components/common/CopyButton.tsx | 39 ++ src/components/common/StepGuide.tsx | 33 ++ src/components/common/TableOfContents.tsx | 95 ++++ src/components/layout/AppLayout.tsx | 235 ++++++---- src/content/ChatBotIntegration.tsx | 269 ++++++++++++ src/content/DesignSystem.tsx | 476 +++++++++++++++++++++ src/content/DevEnvIntro.tsx | 116 +++++ src/content/GitWorkflow.tsx | 190 ++++++++ src/content/GiteaUsage.tsx | 132 ++++++ src/content/InitialSetup.tsx | 166 +++++++ src/content/NexusUsage.tsx | 116 +++++ src/content/StartingProject.tsx | 227 ++++++++++ src/hooks/ThemeContext.ts | 15 + src/hooks/ThemeProvider.tsx | 62 +++ src/hooks/useScrollSpy.ts | 32 ++ src/hooks/useTheme.ts | 6 + src/index.css | 105 +++++ src/pages/DeniedPage.tsx | 10 +- src/pages/GuidePage.tsx | 46 +- src/pages/HomePage.tsx | 19 +- src/pages/LoginPage.tsx | 46 +- src/pages/PendingPage.tsx | 12 +- src/pages/admin/PermissionManagement.tsx | 171 ++++++++ src/pages/admin/RoleManagement.tsx | 187 ++++++++ src/pages/admin/StatsPage.tsx | 106 +++++ src/pages/admin/UserManagement.tsx | 248 +++++++++++ src/types/index.ts | 28 ++ src/utils/api.ts | 4 + src/utils/navigation.ts | 1 + 45 files changed, 3565 insertions(+), 232 deletions(-) create mode 100755 .claude/scripts/on-commit.sh create mode 100755 .claude/scripts/on-post-compact.sh create mode 100755 .claude/scripts/on-pre-compact.sh create mode 100644 src/auth/AuthContext.ts create mode 100644 src/components/common/Alert.tsx create mode 100644 src/components/common/CodeBlock.tsx create mode 100644 src/components/common/CopyButton.tsx create mode 100644 src/components/common/StepGuide.tsx create mode 100644 src/components/common/TableOfContents.tsx create mode 100644 src/content/ChatBotIntegration.tsx create mode 100644 src/content/DesignSystem.tsx create mode 100644 src/content/DevEnvIntro.tsx create mode 100644 src/content/GitWorkflow.tsx create mode 100644 src/content/GiteaUsage.tsx create mode 100644 src/content/InitialSetup.tsx create mode 100644 src/content/NexusUsage.tsx create mode 100644 src/content/StartingProject.tsx create mode 100644 src/hooks/ThemeContext.ts create mode 100644 src/hooks/ThemeProvider.tsx create mode 100644 src/hooks/useScrollSpy.ts create mode 100644 src/hooks/useTheme.ts create mode 100644 src/pages/admin/PermissionManagement.tsx create mode 100644 src/pages/admin/RoleManagement.tsx create mode 100644 src/pages/admin/StatsPage.tsx create mode 100644 src/pages/admin/UserManagement.tsx diff --git a/.claude/scripts/on-commit.sh b/.claude/scripts/on-commit.sh new file mode 100755 index 0000000..f473403 --- /dev/null +++ b/.claude/scripts/on-commit.sh @@ -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 </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 diff --git a/.claude/scripts/on-pre-compact.sh b/.claude/scripts/on-pre-compact.sh new file mode 100755 index 0000000..3f52f09 --- /dev/null +++ b/.claude/scripts/on-pre-compact.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) +INPUT=$(cat) +cat < +# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> +# 예시: +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" +curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig" +``` -### 4. Git Hooks 설정 +### 3. .claude/ 디렉토리 구성 +이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드: +- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조) +- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing) +- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project) + +### 4. Hook 스크립트 생성 +`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x): + +- `.claude/scripts/on-pre-compact.sh`: + +```bash +#!/bin/bash +# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) +INPUT=$(cat) +cat </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 </memory/`) 다음 파일들을 생성: + +- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내) + - 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크 +- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트 +- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작 +- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약 +- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록) +- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴) + +### 10. Lint 도구 확인 +- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안 +- Java: checkstyle, spotless 등 설정 확인 +- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인 + +### 11. workflow-version.json 생성 +Gitea API로 최신 팀 워크플로우 버전을 조회: +```bash +curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json" +``` +조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용. + +`.claude/workflow-version.json` 파일 생성: ```json { - "applied_global_version": "1.0.0", - "applied_date": "현재날짜", - "project_type": "감지된타입" + "applied_global_version": "<조회된 버전>", + "applied_date": "<현재날짜>", + "project_type": "<감지된타입>", + "gitea_url": "https://gitea.gc-si.dev" } ``` -### 8. 검증 및 요약 +### 12. 검증 및 요약 - 생성/수정된 파일 목록 출력 - `git config core.hooksPath` 확인 - 빌드 명령 실행 가능 확인 -- 다음 단계 안내 (개발 시작, 첫 커밋 방법 등) +- Hook 스크립트 실행 권한 확인 +- 다음 단계 안내: + - 개발 시작, 첫 커밋 방법 + - 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec` diff --git a/.claude/skills/sync-team-workflow/SKILL.md b/.claude/skills/sync-team-workflow/SKILL.md index 43dd367..930d04d 100644 --- a/.claude/skills/sync-team-workflow/SKILL.md +++ b/.claude/skills/sync-team-workflow/SKILL.md @@ -13,11 +13,11 @@ Gitea API로 template-common 리포의 workflow-version.json 조회: ```bash GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev") -curl -sf "${GITEA_URL}/api/v1/repos/gc/template-common/raw/workflow-version.json" +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json" ``` ### 2. 버전 비교 -로컬 `.claude/workflow-version.json`과 비교: +로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교: - 버전 일치 → "최신 버전입니다" 안내 후 종료 - 버전 불일치 → 미적용 변경 항목 추출하여 표시 @@ -26,8 +26,19 @@ curl -sf "${GITEA_URL}/api/v1/repos/gc/template-common/raw/workflow-version.json 1. `.claude/workflow-version.json`의 `project_type` 필드 확인 2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts +### Gitea 파일 다운로드 URL 패턴 +⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가): +```bash +GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}" +# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로> +# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> +# 예시: +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" +curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig" +``` + ### 4. 파일 다운로드 및 적용 -Gitea API로 해당 타입 + common 템플릿 파일 다운로드: +위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드: #### 4-1. 규칙 파일 (덮어쓰기) 팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체: @@ -42,13 +53,17 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드: #### 4-2. settings.json (부분 갱신) - `deny` 목록: 글로벌 최신으로 교체 - `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합 -- `hooks`: 글로벌 최신으로 교체 +- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가) + - SessionStart(compact) → on-post-compact.sh + - PreCompact → on-pre-compact.sh + - PostToolUse(Bash) → on-commit.sh #### 4-3. 스킬 파일 (덮어쓰기) ``` .claude/skills/create-mr/SKILL.md .claude/skills/fix-issue/SKILL.md .claude/skills/sync-team-workflow/SKILL.md +.claude/skills/init-project/SKILL.md ``` #### 4-4. Git Hooks (덮어쓰기 + 실행 권한) @@ -56,13 +71,23 @@ Gitea API로 해당 타입 + common 템플릿 파일 다운로드: chmod +x .githooks/* ``` +#### 4-5. Hook 스크립트 갱신 +init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기: +``` +.claude/scripts/on-pre-compact.sh +.claude/scripts/on-post-compact.sh +.claude/scripts/on-commit.sh +``` +실행 권한 부여: `chmod +x .claude/scripts/*.sh` + ### 5. 로컬 버전 업데이트 `.claude/workflow-version.json` 갱신: ```json { "applied_global_version": "새버전", "applied_date": "오늘날짜", - "project_type": "감지된타입" + "project_type": "감지된타입", + "gitea_url": "https://gitea.gc-si.dev" } ``` diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 682c62b..fb01d6e 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,5 +1,5 @@ { - "applied_global_version": "1.1.0", + "applied_global_version": "1.2.0", "applied_date": "2026-02-14", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev" diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 93bb350..08dbad7 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -22,11 +22,9 @@ fi # - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수) # - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택) # - subject: 1~72자, 한/영 혼용 허용 (필수) -PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$' - FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE") -if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then +if ! echo "$FIRST_LINE" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9._-]+\)|(\([^)]+\)))?: .{1,72}$'; then echo "" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║" diff --git a/CLAUDE.md b/CLAUDE.md index 4a2e005..336cb03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ npm run lint # ESLint 검사 ## 현재 구현 상태 -### 완료 (scaffold) +### 완료 - 프로젝트 초기화 (Vite + React + TypeScript + Tailwind CSS v4) - 인증 시스템 뼈대: AuthProvider, useAuth, ProtectedRoute, AdminRoute - 페이지 뼈대: LoginPage, PendingPage, DeniedPage, HomePage, GuidePage @@ -46,39 +46,21 @@ npm run lint # ESLint 검사 - 라우팅 구성 (App.tsx): `/login`, `/pending`, `/denied`, `/`, `/dev/:section`, `/admin/*` - 유틸: api.ts (fetch 래퍼), navigation.ts (메뉴 + ant-style 패턴 매칭) - 타입 정의: User, Role, AuthResponse, NavItem, Issue +- 공통 컴포넌트: CodeBlock, Alert, StepGuide, CopyButton +- 가이드 콘텐츠 7개 섹션 (실제 시스템 정보 검증 완료) +- 디자인 시스템: CSS 변수 기반 테마 (다크모드 준비) - 빌드 검증: `tsc -b && vite build` 성공 ### 미구현 (별도 세션에서 작업) 아래 순서대로 구현 필요: -#### 1단계: 공통 컴포넌트 -- `src/components/common/CodeBlock.tsx` — 코드 블록 (highlight.js + 복사 버튼) -- `src/components/common/Alert.tsx` — 정보/경고/에러 알림 박스 -- `src/components/common/StepGuide.tsx` — 단계별 가이드 UI -- `src/components/common/CopyButton.tsx` — 클립보드 복사 버튼 - -#### 2단계: 가이드 콘텐츠 (7개 섹션) -`src/content/` 디렉토리에 TSX 컴포넌트로 작성: - -| 파일 | URL | 내용 | -|------|-----|------| -| DevEnvIntro.tsx | /dev/env-intro | 인프라 구성도, 서비스 카드, 도메인 테이블 | -| InitialSetup.tsx | /dev/initial-setup | SSH 키, Git 설정, SDKMAN/fnm, Claude Code 설치 | -| GiteaUsage.tsx | /dev/gitea-usage | Google OAuth 로그인, 리포 브라우징, 이슈/MR | -| NexusUsage.tsx | /dev/nexus-usage | Maven/Gradle/npm 프록시 설정, 패키지 배포 | -| GitWorkflow.tsx | /dev/git-workflow | 브랜치 전략, Conventional Commits, 3계층 정책 | -| ChatBotIntegration.tsx | /dev/chat-bot | 스페이스 생성, 봇 명령어, 알림 유형 | -| StartingProject.tsx | /dev/starting-project | 템플릿 비교, 리포 생성, /init-project | - -GuidePage.tsx를 수정하여 section 파라미터에 따라 해당 콘텐츠 컴포넌트를 동적 렌더링. - -#### 3단계: 관리자 페이지 +#### 1단계: 관리자 페이지 - `src/pages/admin/UserManagement.tsx` — 사용자 목록, 승인/거절, 롤 배정 - `src/pages/admin/RoleManagement.tsx` — 롤 CRUD - `src/pages/admin/PermissionManagement.tsx` — 롤별 URL 패턴 CRUD - `src/pages/admin/StatsPage.tsx` — 통계 대시보드 -#### 4단계: 다크모드 + 반응형 +#### 2단계: 다크모드 + 반응형 - `src/hooks/useTheme.ts` — 다크/라이트 모드 토글 (localStorage 저장) - Header에 토글 버튼 추가 - 모바일 반응형: 사이드바 접힘 (hamburger 메뉴) @@ -136,7 +118,7 @@ src/ ├── components/ │ ├── layout/ │ │ └── AppLayout.tsx ✅ (사이드바 + 메인 콘텐츠) -│ └── common/ ⬜ (CodeBlock, Alert, StepGuide, CopyButton) +│ └── common/ ✅ (CodeBlock, Alert, StepGuide, CopyButton) ├── pages/ │ ├── LoginPage.tsx ✅ │ ├── PendingPage.tsx ✅ @@ -144,7 +126,7 @@ src/ │ ├── HomePage.tsx ✅ (퀵링크 카드) │ ├── GuidePage.tsx ✅ (section 기반 동적 렌더링 뼈대) │ └── admin/ ⬜ (UserManagement, RoleManagement 등) -├── content/ ⬜ (7개 가이드 TSX) +├── content/ ✅ (7개 가이드 TSX — 실제 시스템 정보 검증 완료) ├── hooks/ ⬜ (useTheme, useScrollSpy) ├── types/index.ts ✅ (User, Role, AuthResponse, NavItem, Issue) ├── utils/ @@ -185,6 +167,23 @@ POST /api/activity/track → { pagePath } → void GET /api/activity/login-history → LoginHistory[] ``` +## 가이드 콘텐츠 정보 기준 (검증 완료) + +가이드 페이지 콘텐츠는 실제 시스템 정보와 대조 검증 완료. 수정 시 참고: + +| 항목 | 실제 값 | +|------|---------| +| Chat 봇 이름 | SI DevBot (GC Bot 아님) | +| 봇 명령어 | status, teams, link <팀이름>, help (@SI DevBot 멘션 방식) | +| 봇 소스 코드 | gc/gitea-chat-sync (Python Flask) | +| Java 패키지 경로 | com.gcsc (도메인 gcsc.co.kr 역순) | +| npm 배포 레포 | npm-hosted (npm-private 아님) | +| .githooks | commit-msg, post-checkout (pre-commit 없음) | +| 3계층 보호 | 1) 로컬 commit-msg hook, 2) 서버 pre-receive hook, 3) main 브랜치 보호 (MR+리뷰) | +| develop 브랜치 | push 허용 (pre-receive hook 검증 통과 필요), MR 필수 아님 | +| CI/CD | Gitea Actions 미구성 (예정) | +| Nexus 인증 | 프로젝트별 .npmrc / settings.xml에 포함 | + ## UI 스타일 가이드 - Tailwind CSS v4 (index.css에 `@import "tailwindcss"`) - 사이드바: 좌측 고정 w-64, 흰색 배경 diff --git a/src/App.tsx b/src/App.tsx index d43bddf..b378586 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,41 +2,48 @@ 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 ( - - - - {/* Public */} - } /> - } /> - } /> + + + + + {/* Public */} + } /> + } /> + } /> - {/* Protected */} - }> - }> - } /> - } /> + {/* Protected */} + }> + }> + } /> + } /> - {/* Admin */} - }> -

사용자 관리

준비 중

} /> -

롤 관리

준비 중

} /> -

권한 관리

준비 중

} /> -

통계

준비 중

} /> + {/* Admin */} + }> + } /> + } /> + } /> + } /> + - -
-
-
+
+
+
+ ); } diff --git a/src/auth/AuthContext.ts b/src/auth/AuthContext.ts new file mode 100644 index 0000000..2f0f3f2 --- /dev/null +++ b/src/auth/AuthContext.ts @@ -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; + devLogin?: () => void; + logout: () => void; +} + +export const AuthContext = createContext({ + user: null, + token: null, + loading: true, + login: async () => {}, + logout: () => {}, +}); diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index 6faca9c..c006ac3 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -1,5 +1,4 @@ import { - createContext, useCallback, useEffect, useMemo, @@ -8,34 +7,52 @@ import { } from 'react'; import type { AuthResponse, User } from '../types'; import { api } from '../utils/api'; +import { AuthContext } from './AuthContext'; -interface AuthContextValue { - user: User | null; - token: string | null; - loading: boolean; - login: (googleToken: string) => Promise; - logout: () => void; +const DEV_MOCK_USER: User = { + id: 1, + email: 'htlee@gcsc.co.kr', + name: '이현태 (DEV)', + avatarUrl: null, + status: 'ACTIVE', + isAdmin: true, + roles: [{ id: 1, name: 'ADMIN', description: '관리자', urlPatterns: ['/**'] }], + createdAt: new Date().toISOString(), + lastLoginAt: new Date().toISOString(), +}; + +function isDevMockSession(): boolean { + return import.meta.env.DEV && localStorage.getItem('dev-user') === 'true'; } -export const AuthContext = createContext({ - user: null, - token: null, - loading: true, - login: async () => {}, - logout: () => {}, -}); - export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); + const [user, setUser] = useState(() => + isDevMockSession() ? DEV_MOCK_USER : null, + ); const [token, setToken] = useState( () => localStorage.getItem('token'), ); - const [loading, setLoading] = useState(true); + const [initialized, setInitialized] = useState( + () => isDevMockSession() || !localStorage.getItem('token'), + ); const logout = useCallback(() => { + const hadToken = !!localStorage.getItem('token') && !isDevMockSession(); localStorage.removeItem('token'); + localStorage.removeItem('dev-user'); setToken(null); setUser(null); + if (hadToken) { + api.post('/auth/logout').catch(() => {}); + } + }, []); + + const devLogin = useCallback(() => { + localStorage.setItem('dev-user', 'true'); + localStorage.setItem('token', 'dev-mock-token'); + setToken('dev-mock-token'); + setUser(DEV_MOCK_USER); + setInitialized(true); }, []); const login = useCallback(async (googleToken: string) => { @@ -48,22 +65,30 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { - if (!token) { - setLoading(false); - return; - } + if (!token || isDevMockSession()) return; + + let cancelled = false; api .get('/auth/me') - .then(setUser) - .catch(() => { - logout(); + .then((data) => { + if (!cancelled) setUser(data); }) - .finally(() => setLoading(false)); + .catch(() => { + if (!cancelled) logout(); + }) + .finally(() => { + if (!cancelled) setInitialized(true); + }); + return () => { + cancelled = true; + }; }, [token, logout]); + const loading = !initialized; + const value = useMemo( - () => ({ user, token, loading, login, logout }), - [user, token, loading, login, logout], + () => ({ user, token, loading, login, devLogin: import.meta.env.DEV ? devLogin : undefined, logout }), + [user, token, loading, login, devLogin, logout], ); return {children}; diff --git a/src/auth/ProtectedRoute.tsx b/src/auth/ProtectedRoute.tsx index aabe486..f02f29a 100644 --- a/src/auth/ProtectedRoute.tsx +++ b/src/auth/ProtectedRoute.tsx @@ -6,8 +6,8 @@ export function ProtectedRoute() { if (loading) { return ( -
-
+
+
); } diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index 68013c4..318a166 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { AuthContext } from './AuthProvider'; +import { AuthContext } from './AuthContext'; export function useAuth() { return useContext(AuthContext); diff --git a/src/components/common/Alert.tsx b/src/components/common/Alert.tsx new file mode 100644 index 0000000..b3f8804 --- /dev/null +++ b/src/components/common/Alert.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; + +interface AlertProps { + type: 'info' | 'warning' | 'error' | 'success'; + title?: string; + children: ReactNode; +} + +const ICONS: Record = { + info: ( + + + + ), + warning: ( + + + + ), + error: ( + + + + ), + success: ( + + + + ), +}; + +const STYLES: Record = { + 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 ( +
+
+ {ICONS[type]} +
+ {title &&

{title}

} +
{children}
+
+
+
+ ); +} diff --git a/src/components/common/CodeBlock.tsx b/src/components/common/CodeBlock.tsx new file mode 100644 index 0000000..1db2244 --- /dev/null +++ b/src/components/common/CodeBlock.tsx @@ -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(null); + + useEffect(() => { + if (codeRef.current) { + codeRef.current.removeAttribute('data-highlighted'); + hljs.highlightElement(codeRef.current); + } + }, [code, language]); + + const trimmedCode = code.trim(); + + return ( +
+
+ {filename || language || ''} + +
+
+        
+          {trimmedCode}
+        
+      
+
+ ); +} diff --git a/src/components/common/CopyButton.tsx b/src/components/common/CopyButton.tsx new file mode 100644 index 0000000..56c8620 --- /dev/null +++ b/src/components/common/CopyButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/common/StepGuide.tsx b/src/components/common/StepGuide.tsx new file mode 100644 index 0000000..627571c --- /dev/null +++ b/src/components/common/StepGuide.tsx @@ -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 ( +
+ {steps.map((step, index) => ( +
+
+
+ {index + 1} +
+ {index < steps.length - 1 && ( +
+ )} +
+
+

{step.title}

+
{step.content}
+
+
+ ))} +
+ ); +} diff --git a/src/components/common/TableOfContents.tsx b/src/components/common/TableOfContents.tsx new file mode 100644 index 0000000..dcf827c --- /dev/null +++ b/src/components/common/TableOfContents.tsx @@ -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 ( + + ); +} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 0f2eddc..c8c5a17 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -1,98 +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' ? ( + + + + ) : theme === 'dark' ? ( + + + + ) : ( + + + + ); + + const themeLabel = theme === 'light' ? '라이트' : theme === 'dark' ? '다크' : '시스템'; + + const sidebarContent = ( + <> + + +
+ + {user && ( +
+ {user.avatarUrl ? ( + + ) : ( +
+ {user.name[0]} +
+ )} +
+

{user.name}

+ +
+
+ )} +
+ + ); return ( -
- {/* Sidebar */} -