diff --git a/.env.example b/.env.example index 4e78782..206e80d 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,6 @@ DATABASE_URL_DIRECT=postgresql://user:password@host:5432/database APP_URL=http://localhost:3000 NODE_ENV=development -# OpenAI (AI 기능용) -OPENAI_API_KEY=your_openai_api_key - # Study Configuration (optional, can be set via admin commands) STUDY_START_DATE=2024-01-01 TOTAL_ROUNDS=10 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ef29017..82b4174 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @bbbang105 +* @bbbang105 @choihooo diff --git a/.github/auto-assign-config.yaml b/.github/auto-assign-config.yaml index 649c44e..340c2d2 100644 --- a/.github/auto-assign-config.yaml +++ b/.github/auto-assign-config.yaml @@ -2,3 +2,4 @@ addReviewers: true addAssignees: author reviewers: - bbbang105 + - choihooo diff --git a/.gitignore b/.gitignore index 75e12ff..a1877e0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,9 @@ coverage *.tsbuildinfo ### CLAUDE ### -/docs +#/docs # Drizzle migration SQL (로컬 전용, DB에서 직접 실행) packages/shared/drizzle/*.sql +.vercel +scripts diff --git a/CLAUDE.md b/CLAUDE.md index b510113..5cad7de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,13 +1,13 @@ # Blog Study Discord Bot -블로그 글쓰기 스터디 자동화 플랫폼. Discord 봇 + 웹 대시보드 + AI 추천. +블로그 글쓰기 스터디 자동화 플랫폼. Discord 봇 + 웹 대시보드. ## 프로젝트 구조 ``` packages/ ├── bot/ # Discord 봇 (discord.js v14) → AWS EC2 배포 -├── web/ # Next.js 14 대시보드 → Vercel 배포 +├── web/ # Next.js 16 대시보드 → Vercel 배포 └── shared/ # 공유 코드 (DB 스키마, 타입, 유틸) ``` @@ -22,7 +22,6 @@ packages/ | Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터) | | DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) | | Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` | -| AI | OpenAI GPT-4o-mini + text-embedding-3-small (예정) | | 배포 | AWS EC2 (bot), Vercel (web), Supabase (DB + Auth) | ## 개발 명령어 @@ -114,7 +113,7 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화 - **폰트**: Pretendard Variable - **기본 아바타**: DiceBear `fun-emoji` 스타일 (`getDefaultAvatar()` in `utils.ts`) - **아바타 리소스**: [DiceBear](https://www.dicebear.com/styles/) - 30+ 스타일, seed 기반 결정적 아바타 생성, API: `https://api.dicebear.com/9.x/{style}/svg?seed={seed}` -- **상세 스펙**: `docs/UI-DESIGN-SYSTEM.md` 참조 +- **상세 스펙**: `docs/26-03-06-ui-design-system.md` 참조 ## 에이전트 활용 가이드 @@ -153,11 +152,10 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화 - `SUPABASE_SERVICE_KEY`, `DATABASE_URL`, `DATABASE_URL_DIRECT` (DB) - `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_GUILD_ID` - `ADMIN_DISCORD_IDS` (관리자 Discord ID, 쉼표 구분) -- `OPENAI_API_KEY` (AI 기능용, 예정) **env 파일 위치** (2곳): -- `/Users/hansangho/Desktop/study-admin/.env.local` — 루트 (shared/bot용) -- `/Users/hansangho/Desktop/study-admin/packages/web/.env.local` — Next.js용 +- `.env.local` — 루트 (shared/bot용) +- `packages/web/.env.local` — Next.js용 **주의**: `packages/web/.env.local`에도 동일 환경변수 필요 (Next.js는 패키지 디렉토리 기준) @@ -167,7 +165,7 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화 ```bash cd packages/shared -export $(grep DATABASE_URL /Users/hansangho/Desktop/study-admin/.env.local | head -1 | xargs) +export $(grep DATABASE_URL ../../.env.local | head -1 | xargs) npx drizzle-kit push --force ``` @@ -176,9 +174,16 @@ npx drizzle-kit push --force | 문서 | 설명 | |------|------| | `docs/ARCHITECTURE.md` | 시스템 아키텍처 (Mermaid 다이어그램) | -| `docs/TECH-DECISIONS.md` | 기술 선택 근거 (ADR) | -| `docs/UI-DESIGN-SYSTEM.md` | UI 디자인 시스템 스펙 | -| `docs/DEVELOPMENT.md` | 개발 환경 설정 | -| `docs/CHECKLIST.md` | 구현 체크리스트 | -| `docs/schema-summary.md` | DB 스키마 요약 (테이블/Enum/FK) | -| `docs/patterns.md` | API 패턴 & 코드 규칙 | +| `docs/26-03-06-tech-decisions.md` | 기술 선택 근거 (ADR) | +| `docs/26-03-06-ui-design-system.md` | UI 디자인 시스템 스펙 | +| `docs/26-03-06-development.md` | 개발 환경 설정 | +| `docs/26-03-06-checklist.md` | 구현 체크리스트 | +| `docs/26-03-06-schema-summary.md` | DB 스키마 요약 (테이블/Enum/FK) | +| `docs/26-03-06-patterns.md` | API 패턴 & 코드 규칙 | + +## docs 파일명 컨벤션 + +`yy-mm-dd-{설명}.md` — 예: `26-03-03-system-architecture.md` +- 설명은 다른 문서와 구분될 정도로 구체적으로 작명 +- `docs/plans/` 하위도 동일 컨벤션 적용 +- 단, `docs/ARCHITECTURE.md`는 제외하며 업데이트 시에도 네이밍을 그대로 유지 diff --git a/README.md b/README.md index 8054e19..39cb161 100644 --- a/README.md +++ b/README.md @@ -15,21 +15,20 @@ packages/ | 영역 | 기술 | |------|------| -| Frontend | Next.js 14, React 18, Tailwind CSS, shadcn/ui | +| Frontend | Next.js 16, React 19, Tailwind CSS v4, shadcn/ui | | Backend | Next.js API Routes, Supabase | | Bot | discord.js v14, pg-boss | -| DB | PostgreSQL (Supabase), Drizzle ORM, pgvector | -| AI | OpenAI GPT-4o-mini, text-embedding-3-small | -| Deploy | Vercel (Web), Railway (Bot), Supabase (DB) | +| DB | PostgreSQL (Supabase), Drizzle ORM | +| Deploy | Vercel (Web), AWS EC2 (Bot), Supabase (DB) | ## 주요 기능 - **RSS 자동 수집** — 블로그 글 발행 감지 및 수집 -- **AI 요약 & 추천** — GPT 기반 글 요약, 키워드 추출, 유사 글 추천 - **출석 자동화** — 2주 1회차, 지각/결석 자동 판정 - **벌금 관리** — 자동 부과, DM 알림, 납부 확인 -- **랭킹 & 통계** — 포스트 수, 출석률 기반 실시간 랭킹 -- **큐레이션** — 관심 키워드 기반 컨퍼런스/아티클 추천 +- **활동 점수 & 랭킹** — 포스트 수, 출석률, 활동 기반 실시간 랭킹 +- **큐레이션** — 태그 + 관심 키워드 기반 컨퍼런스/아티클 추천 +- **커뮤니티 게시판** — 공지/일반/자유 게시글 + 댓글 ## 시작하기 @@ -57,7 +56,7 @@ pnpm typecheck ## 문서 - [아키텍처](docs/ARCHITECTURE.md) -- [기술 결정](docs/TECH-DECISIONS.md) -- [UI 디자인 시스템](docs/UI-DESIGN-SYSTEM.md) -- [개발 환경](docs/DEVELOPMENT.md) -- [구현 체크리스트](docs/CHECKLIST.md) +- [기술 결정](docs/26-03-06-tech-decisions.md) +- [UI 디자인 시스템](docs/26-03-06-ui-design-system.md) +- [개발 환경](docs/26-03-06-development.md) +- [구현 체크리스트](docs/26-03-06-checklist.md) diff --git a/docs/26-03-06-checklist.md b/docs/26-03-06-checklist.md new file mode 100644 index 0000000..4a73f7e --- /dev/null +++ b/docs/26-03-06-checklist.md @@ -0,0 +1,128 @@ +# 구현 체크리스트 + +전면 개편 작업 순서. 의존성 순서대로 정렬. + +--- + +## Phase 0: 기반 정리 ✅ + +- [x] 기존 `.kiro/`, `.vscode/`, 구 `docs/` 파일 제거 +- [x] 새 `CLAUDE.md` 및 `docs/` 문서 체계 확립 +- [x] Git 초기 커밋 (새 출발점) + +## Phase 3: 인증 전환 (Supabase Auth) ✅ + +> Phase 1보다 먼저 진행됨 (인증이 모든 기능의 기반) + +- [x] Supabase 프로젝트에 Discord OAuth 프로바이더 설정 +- [x] `@supabase/ssr` 기반 클라이언트 설정 (`packages/web/src/lib/supabase/`) +- [x] 로그인 페이지: "Discord로 로그인" 버튼으로 교체 +- [x] 회원가입 페이지 제거 (Discord OAuth로 통합) +- [x] `middleware.ts` → Supabase Auth 세션 체크로 교체 +- [x] 관리자 권한: Discord ID + `ADMIN_DISCORD_IDS` 매칭 +- [x] 기존 auth 관련 파일/라우트 제거 +- [x] `api/auth/callback` 라우트 추가 (Supabase OAuth 콜백) +- [x] 모든 API 라우트 Supabase Auth 기반으로 리팩토링 +- [x] DB 스키마에서 `users`, `sessions` 테이블 제거 +- [x] 보안 강화 (오픈 리다이렉트 방지, 입력 검증) +- [x] 불필요한 의존성 제거: `bcryptjs`, `jsonwebtoken`, `jose`, `resend` +- [x] DB 연결: Transaction Pooler 지원 (`prepare: false`) +- [x] 빌드 검증 통과 +- [ ] RLS 정책 설정 (배포 시) + +## Phase 1: 의존성 업그레이드 ✅ + +- [x] Next.js 14 → 16 업그레이드 (PR #5) +- [x] Tailwind CSS v3 → v4 업그레이드 (PR #5) +- [x] React 18 → 19 업그레이드 (PR #5) +- [x] `rss-parser` → `feedsmith` 교체 +- [x] `node-cron` → `pg-boss` 교체 +- [x] 전체 빌드 확인 (`pnpm build`) + +## Phase 4: Bot 개편 (pg-boss + feedsmith) ✅ + +- [x] `feedsmith` 기반 RSS 서비스 재구현 +- [x] `pg-boss` 기반 스케줄러 전환 (6개 잡) +- [x] RSS→PostService.create→NotificationService 파이프라인 연결 +- [x] graceful shutdown에 pg-boss 정리 추가 + +## Phase 6: 웹 UI 전면 리디자인 + +### 6-1: 디자인 시스템 기반 +- [ ] globals.css 컬러 토큰 재정의 (스카이블루 포인트) +- [x] Pretendard 폰트 설정 +- [x] Tailwind CSS v4 + 디자인 토큰 설정 +- [x] next-themes 다크모드 설정 +- [x] shadcn/ui 컴포넌트 테마 커스터마이징 + +### 6-2: 레이아웃 ✅ +- [x] AppLayout (사이드바 + 헤더 + 메인) +- [x] Sidebar (접기/펼치기, 활성 인디케이터, 아이콘 모드) +- [x] 반응형: 모바일 드로어, 태블릿/데스크톱 사이드바 + +### 6-3: Public 페이지 +- [ ] 랜딩 페이지 (/) - 스터디 소개, 로그인 유도 +- [x] 로그인 페이지 (/login) - Discord OAuth 버튼 + +### 6-4: 사용자 페이지 ✅ +- [x] 대시보드 (/dashboard) - 현재 회차, 내 출석, 최근 글 +- [x] 글 목록 (/posts) - 스터디원 글 + 수동 등록 + 페이지네이션 +- [x] 랭킹 (/ranking) - 포디움, 정렬 (총점/포스트/활동), 출석 히트맵 +- [x] 큐레이션 (/curation) - 추천/최신 정렬, 카테고리/태그 필터, 무한 스크롤 +- [x] 게시판 (/board) - 카테고리별 게시글 + 댓글 + 비밀글 + Tiptap 에디터 +- [x] 멤버 목록 (/members) - 활동 멤버 그리드 +- [x] 멤버 프로필 (/members/[id]) - 상세 프로필 + 활동 통계 +- [x] 프로필 (/profile) - 내 정보 조회/수정 +- [x] 온보딩 (/profile/onboarding) - 최초 가입 설정 + +### 6-5: 관리자 페이지 +- [x] 관리자 대시보드 (/admin) - 요약 통계, 활동 피드 +- [x] 멤버 관리 (/admin/members) - CRUD + 승인 + 상태 관리 +- [ ] 출석 관리 (/admin/attendance) - 멤버 × 회차 그리드 (검증 필요) +- [ ] 벌금 관리 (/admin/fines) - 납부/면제 처리 (검증 필요) +- [ ] 점수 관리 (/admin/scores) - 수동 부여/삭제 (검증 필요) +- [x] 큐레이션 소스 (/admin/curation) - 소스 관리 + 크롤링 +- [ ] 설정 (/admin/settings) - 스터디 설정 (검증 필요) + +### 6-6: Supabase Realtime 통합 (선택적) +- [ ] 활동 피드 컴포넌트 (실시간 업데이트) +- [ ] 대시보드 통계 실시간 반영 + +## Phase 7: 통합 테스트 및 배포 + +- [ ] 전체 빌드 성공 확인 +- [ ] 타입 체크 통과 +- [ ] 린트 통과 +- [ ] Supabase 프로덕션 설정 + - RLS 정책 + - Discord OAuth 프로바이더 +- [ ] AWS EC2 배포 (Bot) + - 환경 변수 설정 + - 빌드 확인 +- [ ] Vercel 배포 (Web) + - 환경 변수 설정 + - Supabase URL/키 설정 + - 빌드 확인 +- [ ] E2E 수동 테스트 + - Discord 봇 커맨드 전체 동작 + - 웹 로그인 플로우 + - RSS 수집 → 알림 파이프라인 + - 관리자 페이지 전체 기능 + +--- + +## 우선순위 요약 + +``` +Phase 0 (정리) ✅ + ↓ +Phase 3 (인증) ✅ + ↓ +Phase 1 (의존성) ✅ + ↓ +Phase 4 (봇) ✅ + ↓ +Phase 6 (UI) ←── 관리자 페이지 검증 + 랜딩 + 디자인 정비 남음 + ↓ +Phase 7 (배포) +``` diff --git a/docs/26-03-06-development.md b/docs/26-03-06-development.md new file mode 100644 index 0000000..410d510 --- /dev/null +++ b/docs/26-03-06-development.md @@ -0,0 +1,206 @@ +# 개발 환경 설정 + +## 사전 요구사항 + +- Node.js 22 이상 +- pnpm 8 이상 (`npm install -g pnpm`) +- Discord 개발자 계정 ([discord.com/developers](https://discord.com/developers/applications)) +- Supabase 프로젝트 ([supabase.com](https://supabase.com)) + +## 초기 설정 + +```bash +# 1. 의존성 설치 +pnpm install + +# 2. 환경 변수 설정 +cp .env.example .env.local +cp .env.example packages/web/.env.local +# 두 파일 모두 값을 채워야 합니다 (Next.js는 packages/web/ 기준으로 읽음) + +# 3. shared 패키지 빌드 (web/bot이 의존) +pnpm --filter @blog-study/shared build + +# 4. DB 스키마 푸시 +DATABASE_URL="your-connection-string" pnpm --filter @blog-study/shared db:push + +# 5. 스터디 회차 초기화 +pnpm --filter @blog-study/bot init-rounds + +# 6. Discord 슬래시 커맨드 등록 +pnpm --filter @blog-study/bot deploy-commands +``` + +## 개발 서버 + +```bash +# 봇과 웹을 각각 별도 터미널에서 실행 +pnpm dev:bot # 봇 (tsx watch) +pnpm dev:web # 웹 (localhost:3000) + +# 포트 지정 +pnpm --filter @blog-study/web dev --port 3200 +``` + +## 환경 변수 + +루트 `.env.local`과 `packages/web/.env.local` 두 곳에 동일하게 설정 필요. + +```env +# Supabase +NEXT_PUBLIC_SUPABASE_URL= # 프로젝트 URL (https://xxx.supabase.co) +NEXT_PUBLIC_SUPABASE_ANON_KEY= # 클라이언트용 공개 키 +SUPABASE_SERVICE_KEY= # 서버용 비밀 키 +DATABASE_URL= # Transaction Pooler URL + # postgresql://postgres.xxx:[password]@aws-1-ap-northeast-2.pooler.supabase.com:6543/postgres + +# Discord +DISCORD_TOKEN= # 봇 토큰 +DISCORD_CLIENT_ID= # 클라이언트 ID +DISCORD_CLIENT_SECRET= # 클라이언트 시크릿 (OAuth용) +DISCORD_GUILD_ID= # 서버(길드) ID + +# Admin +ADMIN_DISCORD_IDS=id1,id2 # 관리자 Discord User ID (쉼표 구분) + +# Application +APP_URL=http://localhost:3000 # 웹 URL +NODE_ENV=development + +# Study Config +STUDY_START_DATE=2024-01-01 # 스터디 시작일 +TOTAL_ROUNDS=10 # 총 회차 수 +``` + +### 환경변수 주의사항 + +- `NEXT_PUBLIC_` 접두사가 있는 변수만 브라우저에 노출됨 +- `packages/web/.env.local`이 없으면 Next.js가 환경변수를 읽지 못함 +- `DATABASE_URL`은 **Transaction Pooler** URL 사용 (Direct connection은 DNS 미지원 가능) + +## 테스트 + +```bash +# 전체 테스트 +pnpm test + +# 패키지별 +pnpm --filter @blog-study/bot test +pnpm --filter @blog-study/shared test + +# 워치 모드 +pnpm --filter @blog-study/bot test:watch +``` + +## 빌드 + +```bash +# 전체 빌드 (shared → bot/web 순서) +pnpm build + +# 패키지별 +pnpm build:bot # tsup으로 번들링 → dist/ +pnpm build:web # next build +``` + +## 코드 품질 + +```bash +pnpm lint # ESLint +pnpm format # Prettier (자동 수정) +pnpm format:check # Prettier (검사만) +pnpm typecheck # TypeScript 타입 체크 +``` + +## Supabase 설정 + +### Discord OAuth 프로바이더 +1. Supabase Dashboard → Authentication → Providers → Discord +2. `DISCORD_CLIENT_ID`와 `DISCORD_CLIENT_SECRET` 입력 +3. Callback URL을 Discord Developer Portal에 등록 + +### Redirect URL 설정 +1. Supabase Dashboard → Authentication → URL Configuration +2. Redirect URLs에 추가: + - `http://localhost:3000/auth/callback` (로컬) + - `http://localhost:3200/auth/callback` (로컬 대체 포트) + - `https://your-domain.vercel.app/auth/callback` (프로덕션) + +### pg-boss 설정 +pg-boss는 첫 실행 시 자동으로 필요한 테이블을 생성합니다. + +## Discord 봇 설정 + +### 1. 봇 생성 +1. [Discord Developer Portal](https://discord.com/developers/applications) 접속 +2. "New Application" → 이름 입력 +3. "Bot" 탭 → "Add Bot" +4. Token 복사 → `DISCORD_TOKEN`에 입력 +5. **Privileged Gateway Intents** 모두 활성화: + - PRESENCE INTENT + - SERVER MEMBERS INTENT + - MESSAGE CONTENT INTENT + +### 2. OAuth2 설정 +1. "OAuth2" → "General" +2. Redirects에 Supabase 콜백 URL 추가 +3. Client Secret 복사 → `DISCORD_CLIENT_SECRET`에 입력 + +### 3. 봇 초대 +1. "OAuth2" → "URL Generator" +2. Scopes: `bot`, `applications.commands` +3. Bot Permissions: `Send Messages`, `Manage Roles`, `Embed Links`, `Add Reactions`, `Use Slash Commands` +4. 생성된 URL로 서버에 초대 + +## 프로젝트 구조 + +``` +study-admin/ +├── CLAUDE.md # Claude Code 프로젝트 설정 +├── .env.example # 환경 변수 템플릿 +├── .env.local # 로컬 환경 변수 (gitignored) +├── package.json # 루트 패키지 (스크립트) +├── pnpm-workspace.yaml # 모노레포 설정 +├── tsconfig.json # 공통 TypeScript 설정 +├── vercel.json # Vercel 배포 설정 +├── docs/ # 프로젝트 문서 +│ ├── ARCHITECTURE.md # 시스템 아키텍처 +│ ├── TECH-DECISIONS.md # 기술 선택 근거 (ADR) +│ ├── UI-DESIGN-SYSTEM.md # UI 디자인 시스템 +│ ├── DEVELOPMENT.md # (이 파일) +│ └── CHECKLIST.md # 구현 체크리스트 +└── packages/ + ├── bot/ # Discord 봇 + │ ├── src/ + │ │ ├── index.ts # 엔트리포인트 + │ │ ├── bot.ts # Discord 클라이언트 + │ │ ├── commands/ # 슬래시 커맨드 + │ │ ├── services/ # 비즈니스 로직 + │ │ ├── schedulers/ # 크론/잡 큐 + │ │ ├── handlers/ # 이벤트 핸들러 + │ │ └── scripts/ # 초기화 스크립트 + │ └── package.json + ├── web/ # Next.js 대시보드 + │ ├── .env.local # 웹 전용 환경 변수 (gitignored) + │ ├── middleware.ts # 라우트 보호 미들웨어 + │ ├── src/ + │ │ ├── app/ # App Router 페이지 + │ │ │ ├── auth/callback/ # OAuth 콜백 + │ │ │ ├── (auth)/ # 로그인 페이지 + │ │ │ ├── (user)/ # 사용자 페이지 + │ │ │ └── (admin)/ # 관리자 페이지 + │ │ ├── components/ # UI 컴포넌트 (shadcn/ui) + │ │ └── lib/ # 유틸리티 + │ │ ├── supabase/ # Supabase 클라이언트 (client/server/middleware) + │ │ ├── admin.ts # 관리자 권한 + │ │ ├── db.ts # DB 연결 + │ │ └── api-error.ts # API 에러 처리 + │ └── package.json + └── shared/ # 공유 코드 + ├── src/ + │ ├── db/ # Drizzle 스키마, 연결 + │ ├── config/ # 설정 + │ └── utils/ # 유틸리티 함수 + ├── drizzle.config.ts # Drizzle Kit 설정 + └── package.json +``` diff --git a/docs/26-03-06-patterns.md b/docs/26-03-06-patterns.md new file mode 100644 index 0000000..cc2e8c5 --- /dev/null +++ b/docs/26-03-06-patterns.md @@ -0,0 +1,232 @@ +# API 패턴 & 코드 규칙 + +## 관리자 API 인증 패턴 + +### withAdminAuth 래퍼 (권장) + +```ts +// packages/web/src/lib/admin.ts 의 withAdminAuth 사용 +import { withAdminAuth } from '@/lib/admin'; + +export const GET = withAdminAuth(async (request: NextRequest, adminAuth) => { + // adminAuth.discordId — 관리자의 Discord ID + // adminAuth.userId — Supabase user ID + const database = db(); + // ... DB 조회 로직 + return NextResponse.json({ data }); +}); +``` + +### 인증 흐름 +``` +createClient() → getUser() → identities[].find(p => p.provider === 'discord').id +→ isAdminDiscordId(discordId) → 관리자 확인 +``` + +### 관련 파일 +| 파일 | 역할 | +|------|------| +| `packages/web/src/lib/admin.ts` | `withAdminAuth`, `verifyAdminAccess`, `isAdminDiscordId` | +| `packages/web/src/lib/supabase/server.ts` | `createClient()` — 서버용 Supabase | +| `packages/web/src/lib/supabase/client.ts` | 브라우저용 Supabase | +| `packages/web/src/lib/db.ts` | `db()` — shared DB 인스턴스 래퍼 | + +## Drizzle ORM Import 패턴 + +```ts +// shared 패키지에서 스키마 + enum import +import { db as sharedDb } from '@blog-study/shared'; +const { members, posts, attendance, MemberStatus, AttendanceStatus } = sharedDb; + +// drizzle-orm 연산자 +import { eq, count, sql, asc, desc, and, or, inArray } from 'drizzle-orm'; + +// DB 인스턴스 (web 패키지) +import { db } from '@/lib/db'; +const database = db(); +``` + +## API Route 기본 구조 (Next.js App Router) + +```ts +// packages/web/src/app/api/admin/{resource}/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; + +const { members, MemberStatus } = sharedDb; + +// GET — 목록 조회 +export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const { searchParams } = new URL(request.url); + const database = db(); + const result = await database.select().from(members); + return NextResponse.json({ data: result }); + } catch (error) { + console.error('API error:', error); + return NextResponse.json({ message: '서버 오류' }, { status: 500 }); + } +}); + +// POST — 생성 +export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const body = await request.json(); + // validation → insert → returning + return NextResponse.json({ message: '성공', data: newItem }); + } catch (error) { + return NextResponse.json({ message: '서버 오류' }, { status: 500 }); + } +}); +``` + +### 동적 라우트 (패턴: `/api/admin/{resource}/[id]/route.ts`) + +```ts +// PATCH — 수정 +export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { + const id = request.url.split('/').pop(); // 또는 params에서 추출 + // update → returning +}); + +// DELETE — 삭제 +export const DELETE = withAdminAuth(async (request: NextRequest, _adminAuth) => { + const id = request.url.split('/').pop(); + // delete → returning +}); +``` + +## 일반 사용자 API 패턴 + +```ts +// 인증만 필요, 관리자 권한 불필요 +import { createClient } from '@/lib/supabase/server'; + +export async function GET() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: '인증 필요' }, { status: 401 }); + + const discordId = user.identities?.find(i => i.provider === 'discord')?.id; + // discordId로 members 테이블 조회 +} +``` + +## 멤버 상태 리다이렉트 패턴 + +서버 + 클라이언트 이중 체크로 차단 상태 사용자의 접근을 제어: + +### 1. OAuth 콜백 (서버 사이드) + +```ts +// packages/web/src/app/auth/callback/route.ts +// 로그인 직후 DB에서 status 조회 → 상태별 리다이렉트 +if (!memberData || !memberData.onboardingCompleted) → /profile/onboarding +if (memberData.status === 'pending_approval') → /pending +if (memberData.status === 'inactive') → /inactive +``` + +### 2. UserLayout (클라이언트 사이드) + +```ts +// packages/web/src/app/(user)/layout.tsx +// checkedPathname 패턴: pathname 변경 시 자동으로 로딩 상태 진입 (플래시 방지) +const [checkedPathname, setCheckedPathname] = useState(null); + +// /api/auth/me 응답의 status 필드로 리다이렉트 판단 +// 차단 페이지 자체는 예외 처리: blockedPages = ['/pending', '/inactive'] + +// 로딩 가드: checkedPathname !== pathname이면 로딩 표시 +if (checkedPathname !== pathname && pathname !== '/profile/onboarding') → 로딩 +``` + +### 주의사항 +- `(admin)` 레이아웃은 별도 인증 → 멤버 상태 체크 없음 (관리자가 스스로 승인 가능) +- `pending`/`inactive` 페이지는 `(user)` 그룹 내에 있지만 리다이렉트 예외 처리됨 + +## 다이얼로그 패턴 + +`window.confirm()`/`window.alert()`/`window.prompt()` 사용 금지. 커스텀 다이얼로그 사용: + +```tsx +// AlertDialog (shadcn/ui) 패턴 — DeletePostDialog 참고 + + + + 제목 + 설명 + + + 취소 + 확인 + + + +``` + +## 게시판 API 인증 패턴 + +관리자 전용이 아닌 일반 사용자 API는 `getBoardAuth` + `successResponse`/`Errors` 조합 사용: + +```ts +// packages/web/src/lib/board-auth.ts +import { getBoardAuth } from '@/lib/board-auth'; +import { successResponse, errorResponse, Errors } from '@/lib/api-error'; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const { id } = await params; + const database = getDb(); + // ... DB 조회 + return successResponse(data); + } catch (error) { + return errorResponse(error); + } +} +``` + +### getBoardAuth vs withAdminAuth +| 함수 | 용도 | 반환 | +|------|------|------| +| `withAdminAuth` | 관리자 전용 API 래퍼 | `adminAuth.discordId`, `adminAuth.userId` | +| `getBoardAuth` | 일반 사용자 API 함수 | `{ memberId, discordId, isAdmin }` 또는 `null` | + +### API 표준 응답 (`api-error.ts`) +| 함수 | 용도 | +|------|------| +| `successResponse(data, message?)` | `{ success: true, data, message }` | +| `errorResponse(error)` | ApiError → 표준 에러 응답, unknown → 500 | +| `Errors.unauthorized()` | 401 | +| `Errors.forbidden(msg)` | 403 | +| `Errors.notFound(msg)` | 404 | +| `Errors.badRequest(msg)` | 400 | + +## MemberAvatar 재사용 컴포넌트 + +프로필 아바타 + 이름 + 멤버 상세 링크 + 관리자 뱃지를 통합 제공: + +```tsx +import { MemberAvatar } from '@/components/ui/member-avatar'; + +// 기본: 아바타만 (클릭 시 멤버 상세로 이동) + + +// 아바타 + 이름 + 관리자 뱃지 + + +// 링크 비활성화 (삭제된 댓글 등) + +``` + +| Prop | 타입 | 설명 | +|------|------|------| +| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | 아바타 크기 | +| `showName` | boolean | 이름 표시 + 링크 포함 | +| `noLink` | boolean | 링크 비활성화 | +| `isAdmin` | boolean | 관리자 뱃지 표시 | diff --git a/docs/26-03-06-schema-summary.md b/docs/26-03-06-schema-summary.md new file mode 100644 index 0000000..e008739 --- /dev/null +++ b/docs/26-03-06-schema-summary.md @@ -0,0 +1,159 @@ +# DB 스키마 요약 + +> 소스: `packages/shared/src/db/schema.ts` + +## Enum 타입 + +| Enum | 값 | 용도 | +|------|-----|------| +| `MemberStatus` | `pending_approval`, `active`, `inactive`, `dormant`, `ob`, `withdrawn` | 멤버 상태 | +| `AttendanceStatus` | `pending`, `submitted`, `late`, `absent` | 출석 상태 | +| `FineType` | `late`, `absent` | 벌금 사유 | +| `FineStatus` | `unpaid`, `paid`, `waived` | 벌금 납부 | +| `CurationCategory` | `conference`, `article` | 큐레이션 분류 | +| `ActivityScoreType` | `blog_post`, `discord_message`, `discord_thread`, `discord_reaction`, `admin_manual`, `post_view` | 활동 점수 | +| `BoardCategory` | `notice`, `suggestion`, `review`, `knowledge`, `daily`, `etc` | 게시판 카테고리 (const object) | + +## 테이블 + +### members +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `discord_id` | varchar(20) | unique, not null | +| `discord_username` | varchar(100) | not null | +| `name` | varchar(50) | not null | +| `nickname` | varchar(100) | not null | +| `part` | varchar(50) | not null | +| `blog_url` | varchar(500) | not null | +| `rss_url` | varchar(500) | nullable | +| `profile_image_url` | varchar(500) | nullable | +| `bio` | varchar(200) | nullable | +| `interests` | text[] | nullable | +| `resolution` | varchar(300) | nullable | +| `onboarding_completed` | boolean | default false | +| `rss_consent` | boolean | default true | +| `github_url`, `linkedin_url`, `instagram_url` | varchar(500) | 소셜 링크 | +| `status` | varchar(20) | default 'active' | +| `dormant_start_round` | integer | nullable | +| `dormant_used` | boolean | default false | +| `joined_at`, `updated_at` | timestamptz | defaultNow | + +### rounds +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | serial PK | | +| `round_number` | integer | unique, not null | +| `start_date`, `end_date`, `grace_end_date` | date | not null | +| `is_current` | boolean | default false | + +### posts +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `round_id` | integer FK → rounds | nullable | +| `title` | varchar(500) | not null | +| `url` | varchar(1000) | unique, not null | +| `published_at` | timestamptz | not null | +| `description` | text | nullable | +| `collected_at` | timestamptz | defaultNow | + +### attendance +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `round_id` | integer FK → rounds | not null | +| `status` | varchar(20) | default 'pending' | +| `submitted_at` | timestamptz | nullable | +| unique constraint: `(member_id, round_id)` | + +### fines +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `round_id` | integer FK → rounds | not null | +| `type` | varchar(20) | late/absent | +| `amount` | integer | not null | +| `status` | varchar(20) | default 'unpaid' | +| unique constraint: `(member_id, round_id)` | + +### activity_scores +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `type` | varchar(30) | ActivityScoreType | +| `points` | integer | not null | +| `description` | varchar(300) | nullable | +| `date` | date | 일일 상한 체크용 | + +### post_views +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `post_id` | uuid FK → posts | not null | +| unique constraint: `(member_id, post_id)` | + +### config +`key` (varchar PK) / `value` (text) / `updated_at` — 설정 키-값 저장소 + +### board_posts +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `member_id` | uuid FK → members | not null | +| `category` | varchar(20) | BoardCategory, not null | +| `title` | varchar(200) | not null | +| `content` | jsonb | Tiptap JSON, not null | +| `content_text` | text | 검색용 평문, not null | +| `is_secret` | boolean | default false | +| `is_pinned` | boolean | default false | +| `comment_count` | integer | default 0 | +| `created_at`, `updated_at` | timestamptz | defaultNow | +| `deleted_at` | timestamptz | nullable (soft delete) | +| 인덱스: `member_id`, `category`, `is_pinned`, `created_at` | + +### board_comments +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `post_id` | uuid FK → board_posts | not null | +| `member_id` | uuid FK → members | not null | +| `parent_id` | uuid FK → board_comments | nullable (대댓글) | +| `content` | text | not null | +| `is_secret` | boolean | default false | +| `created_at`, `updated_at` | timestamptz | defaultNow | +| `deleted_at` | timestamptz | nullable (soft delete) | +| 인덱스: `post_id`, `member_id`, `parent_id` | + +### keywords, curation_sources, curation_items +- `keywords`: keyword(unique) + frequency + last_updated +- `curation_sources`: url(unique) + name + category + rss_url + tags[] + is_active +- `curation_items`: source_id FK → curation_sources, url(unique) + title + description + thumbnail_url + category + tags[] + relevance_score + is_shared + +## FK 관계 요약 + +``` +members ──< posts (member_id) +members ──< attendance (member_id) +members ──< fines (member_id) +members ──< activity_scores (member_id) +members ──< post_views (member_id) +rounds ──< posts (round_id) +rounds ──< attendance (round_id) +rounds ──< fines (round_id) +posts ──< post_views (post_id) +curation_sources ──< curation_items (source_id) +members ──< board_posts (member_id) +members ──< board_comments (member_id) +board_posts ──< board_comments (post_id) +board_comments ──< board_comments (parent_id, self-ref) +``` + +## 타입 Export + +모든 테이블에 `Type`/`NewType` export 있음 (예: `Member`/`NewMember`, `Post`/`NewPost`, `BoardPost`/`NewBoardPost`, `BoardComment`/`NewBoardComment`) diff --git a/docs/26-03-06-tech-decisions.md b/docs/26-03-06-tech-decisions.md new file mode 100644 index 0000000..6b2ccd5 --- /dev/null +++ b/docs/26-03-06-tech-decisions.md @@ -0,0 +1,121 @@ +# 기술 결정 기록 (ADR) + +## 결정 요약 + +| # | 결정 | 선택 | 대안 | 이유 | +|---|------|------|------|------| +| 1 | Next.js 버전 | **16** | 14 유지, 15 | React 19, Tailwind v4 네이티브 지원 | +| 2 | 인증 | **Supabase Auth** | 커스텀 JWT | Discord OAuth 네이티브, 유지보수 비용 제거 | +| 3 | RSS 파서 | **feedsmith** | rss-parser | rss-parser 3년 미유지보수, feedsmith 활발 | +| 4 | 작업 큐 | **pg-boss** | node-cron, Upstash Redis | 추가 인프라 불필요, PostgreSQL 기반 트랜잭션 | +| 5 | 실시간 | **Supabase Realtime** (선택적) | WebSocket 직접 구현 | 활동 피드만 적용, 20줄로 구현 가능 | + +--- + +## ADR-1: Next.js 16 업그레이드 + +**상태**: ✅ 구현 완료 (PR #5) + +**결정**: Next.js 14 → 16 업그레이드 (React 19, Tailwind CSS v4 포함) + +**근거**: +- React 19: 서버 컴포넌트/액션 안정화 +- Tailwind CSS v4: 네이티브 CSS 기반, 빌드 성능 향상 +- Turbopack 안정화: 빌드/HMR 성능 개선 + +**마이그레이션 내용**: +- `params`/`cookies()`/`headers()` async 변환 +- Tailwind v4 설정 마이그레이션 (PostCSS 기반) +- React 18 → 19 호환성 업데이트 + +--- + +## ADR-2: Supabase Auth로 전환 + +**상태**: ✅ 구현 완료 (PR #2) + +**결정**: 커스텀 이메일/JWT 인증 → Supabase Auth (Discord OAuth) + +**근거**: +- Discord OAuth2 네이티브 지원 (콜백 URL 자동 관리) +- JWT, 세션, 토큰 리프레시 코드 전부 삭제 가능 +- RLS 정책 활용 가능 +- 무료 50,000 MAU (스터디 규모에 충분) + +**아키텍처 분리**: +- 웹: Supabase Auth (Discord OAuth2 PKCE 플로우) +- 봇: `service_role` 키 + Discord ID로 직접 DB 조회/수정 +- 봇은 Auth 레이어 무관 + +**구현 내용**: +- `@supabase/ssr` 기반 SSR 클라이언트 (client/server/middleware) +- Discord ID 추출: `user.identities[].id` where `provider === 'discord'` +- 미들웨어: `updateSession()`으로 세션 자동 갱신 + 라우트 보호 +- 관리자: `ADMIN_DISCORD_IDS` 환경변수로 Discord ID 기반 권한 체크 +- DB: Transaction Pooler 사용 (`prepare: false`) + +**제거 완료**: +- `users`, `sessions` 테이블 (Supabase Auth의 `auth.users`로 대체) +- `packages/web/src/lib/auth.ts` (커스텀 JWT 로직) +- `packages/web/src/lib/email.ts` (이메일 인증) +- `bcryptjs`, `jsonwebtoken`, `jose`, `resend` 의존성 +- 회원가입/이메일인증 페이지 및 API + +--- + +## ADR-3: feedsmith 채택 + +**상태**: ✅ 구현 완료 + +**결정**: rss-parser → feedsmith + +**근거**: +- rss-parser: 마지막 배포 3년 전, 비활성 +- feedsmith: 2025 활발, RSS/Atom/RDF/JSON Feed 모두 지원 +- 피드 생성 기능도 있어 향후 RSS 피드 노출 가능 +- rss-parser보다 빠름 + +**구현 내용**: +- `RssService`: axios fetch + `parseFeed()` 분리 구조 +- `extractFeedItems()` 헬퍼로 RSS/Atom/JSON/RDF 전체 포맷 정규화 +- `detectRssUrl()`, `fetchFeed()` 모두 feedsmith 기반으로 전환 + +--- + +## ADR-4: pg-boss로 작업 큐 전환 + +**상태**: ✅ 구현 완료 + +**결정**: node-cron → pg-boss + +**근거**: +- node-cron: 프로세스 내 메모리 기반, 재시작 시 상태 유실 +- pg-boss: PostgreSQL 기반, 트랜잭션 보장, 재시도/동시성 관리 +- Upstash Redis 불필요: 추가 인프라, 커맨드당 과금, BullMQ 호환 문제 + +**구현 내용**: +- `job-queue.ts`: pg-boss 싱글톤 (start/stop/get) +- `scheduler-registry.ts`: 6개 cron 잡 등록 + 워커 연결 +- `index.ts`에서 pg-boss 시작 → 잡 등록 → graceful shutdown 통합 +- `DATABASE_URL_DIRECT` 환경변수 추가 (pg-boss LISTEN/NOTIFY용) + +**등록된 잡**: +| 잡 이름 | cron | 핸들러 | +|---------|------|--------| +| `rss-poll` | `*/5 * * * *` | `RssPoller.poll()` | +| `attendance-check` | `0 0 * * 2` | `AttendanceChecker.check()` | +| `fine-reminder` | `0 10 * * *` | `FineReminder.sendReminders()` | +| `round-report` | `5 0 * * 2` | `RoundReporter.sendRoundReport()` | +| `curation-crawl` | `0 9 * * *` | `CurationCrawler.crawl()` | +| `curation-share` | `0 10 * * *` | `CurationCrawler.shareDailyContent()` | + +--- + +## 비용 총 요약 + +| 서비스 | 월 비용 | +|--------|--------| +| AWS EC2 (Bot) | 기존 서버 활용 | +| Vercel (Web) | $0 (무료) | +| Supabase (DB + Auth) | $0 (무료) ~ $25 (Pro) | +| **합계** | **$0 ~ $25/월** | diff --git a/docs/26-03-06-ui-design-system.md b/docs/26-03-06-ui-design-system.md new file mode 100644 index 0000000..f6bb301 --- /dev/null +++ b/docs/26-03-06-ui-design-system.md @@ -0,0 +1,286 @@ +# UI 디자인 시스템 + +## 디자인 방향 + +**컨셉**: Vercel 화이트/블랙 미니멀 + 스카이블루 포인트 +- 해외 어드민 사이트 느낌: 깔끔, 세련, 여백 활용 +- 참고: hazel-admin (레이아웃/컴포넌트), obsidian-quartz-blog (타이포/색상 체계) + +--- + +## 컬러 시스템 + +### Light Mode + +| 토큰 | 색상 | 용도 | +|------|------|------| +| `--background` | `#ffffff` | 페이지 배경 | +| `--foreground` | `#18181b` (zinc-900) | 기본 텍스트 | +| `--muted` | `#f4f4f5` (zinc-100) | 비활성 배경, 카드 배경 | +| `--muted-foreground` | `#71717a` (zinc-500) | 보조 텍스트, 플레이스홀더 | +| `--border` | `#e4e4e7` (zinc-200) | 테두리, 구분선 | +| `--primary` | `#0ea5e9` (sky-500) | 주요 액션, 링크, 포인트 | +| `--primary-hover` | `#0284c7` (sky-600) | 호버 상태 | +| `--primary-foreground` | `#ffffff` | primary 위 텍스트 | +| `--primary-muted` | `#e0f2fe` (sky-100) | 포인트 배경, 뱃지 | +| `--secondary` | `#f4f4f5` (zinc-100) | 보조 버튼 배경 | +| `--secondary-foreground` | `#18181b` | 보조 버튼 텍스트 | +| `--destructive` | `#ef4444` (red-500) | 삭제, 에러 | +| `--success` | `#22c55e` (green-500) | 성공, 출석 | +| `--warning` | `#f59e0b` (amber-500) | 경고, 지각 | +| `--accent` | `#f4f4f5` | 사이드바 호버 | +| `--ring` | `#0ea5e9` | 포커스 링 | + +### Dark Mode + +| 토큰 | 색상 | +|------|------| +| `--background` | `#09090b` (zinc-950) | +| `--foreground` | `#fafafa` (zinc-50) | +| `--muted` | `#27272a` (zinc-800) | +| `--muted-foreground` | `#a1a1aa` (zinc-400) | +| `--border` | `#3f3f46` (zinc-700) | +| `--primary` | `#38bdf8` (sky-400) | +| `--primary-hover` | `#0ea5e9` (sky-500) | +| `--primary-muted` | `#0c4a6e` (sky-900) | +| `--secondary` | `#27272a` (zinc-800) | + +### 차트 컬러 + +``` +--chart-1: #0ea5e9 (sky - 출석/제출) +--chart-2: #22c55e (green - 성공) +--chart-3: #f59e0b (amber - 지각) +--chart-4: #ef4444 (red - 결석) +--chart-5: #8b5cf6 (violet - 기타) +``` + +--- + +## 타이포그래피 + +### 폰트 + +```css +--font-sans: "Pretendard Variable", "Pretendard", + -apple-system, BlinkMacSystemFont, system-ui, Roboto, + "Helvetica Neue", "Segoe UI", sans-serif; + +--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, + Menlo, Monaco, Consolas, monospace; +``` + +**Pretendard 로딩**: CDN (`cdn.jsdelivr.net/gh/orioncactus/pretendard`) + +### 크기 스케일 + +| 용도 | 크기 | 무게 | 행간 | +|------|------|------|------| +| h1 (페이지 제목) | `clamp(1.65rem, 1.4rem + 1vw, 2rem)` | 700 | 1.2 | +| h2 (섹션 제목) | `clamp(1.35rem, 1.2rem + 0.6vw, 1.5rem)` | 600 | 1.3 | +| h3 (서브섹션) | `clamp(1.1rem, 1rem + 0.4vw, 1.25rem)` | 600 | 1.4 | +| body | `0.875rem` (14px) | 400 | 1.6 | +| small/label | `0.75rem` (12px) | 500 | 1.4 | +| code | `0.85rem` | 400 | 1.5 | + +### 자간 + +```css +body { letter-spacing: -0.014em; } +h1, h2, h3 { letter-spacing: -0.025em; } +``` + +--- + +## 디자인 토큰 + +### 간격 (Spacing) + +```css +--spacing-xs: 0.25rem; /* 4px */ +--spacing-sm: 0.5rem; /* 8px */ +--spacing-md: 1rem; /* 16px */ +--spacing-lg: 1.5rem; /* 24px */ +--spacing-xl: 2rem; /* 32px */ +--spacing-2xl: 3rem; /* 48px */ +``` + +### 라운딩 (Border Radius) + +```css +--radius: 0.75rem; /* 12px - 기본 */ +--radius-sm: 0.5rem; /* 8px - 뱃지, 인라인 코드 */ +--radius-md: 0.625rem; /* 10px */ +--radius-lg: 0.75rem; /* 12px - 카드 */ +--radius-xl: 1rem; /* 16px - 모달 */ +--radius-full: 9999px; /* 원형 - 아바타 */ +``` + +### 그림자 (Shadow) + +```css +--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); +--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), + 0 2px 4px -2px rgba(0, 0, 0, 0.05); +--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), + 0 4px 6px -4px rgba(0, 0, 0, 0.04); +``` + +### 트랜지션 + +```css +--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); +--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); +``` + +--- + +## 레이아웃 + +### 데스크톱 (≥1024px) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Sidebar (240px) │ Main Content (max-w-7xl, mx-auto) │ +│ │ │ +│ ┌─────────────┐ │ ┌─ Header (sticky) ──────────────┐ │ +│ │ Logo │ │ │ 페이지 제목 [🔔] [🌙] [👤] │ │ +│ │ │ │ └────────────────────────────────┘ │ +│ │ ─────────── │ │ │ +│ │ 대시보드 │ │ ┌─ Content ──────────────────────┐ │ +│ │ 글 목록 │ │ │ │ │ +│ │ 랭킹 │ │ │ p-4 sm:p-6 lg:p-8 │ │ +│ │ 큐레이션 │ │ │ │ │ +│ │ │ │ └────────────────────────────────┘ │ +│ │ ─────────── │ │ │ +│ │ 관리자 │ │ │ +│ │ 멤버 │ │ │ +│ │ 출석 │ │ │ +│ │ 벌금 │ │ │ +│ │ 설정 │ │ │ +│ │ │ │ │ +│ │ ─────────── │ │ │ +│ │ [👤 유저] │ │ │ +│ └─────────────┘ │ │ +└──────────────────────────────────────────────────────────┘ +``` + +### 모바일 (<1024px) + +- 사이드바 → 햄버거 메뉴 (오버레이 드로어) +- 컨텐츠 전체 너비 +- 반응형 패딩: `p-4` + +### 사이드바 기능 + +- 접기/펼치기 (아이콘만 모드) +- localStorage로 상태 유지 +- 활성 메뉴: 좌측 스카이블루 바 인디케이터 +- 접힌 상태: 아이콘 + 툴팁 + +--- + +## 컴포넌트 패턴 + +### 버튼 Variants + +| Variant | 배경 | 텍스트 | 용도 | +|---------|------|--------|------| +| `default` | zinc-900 | white | 주요 액션 | +| `primary` | sky-500 | white | 포인트 액션 (참가, 저장) | +| `secondary` | zinc-100 | zinc-900 | 보조 액션 | +| `outline` | transparent + border | zinc-900 | 3차 액션 | +| `ghost` | transparent | zinc-900 | 사이드바, 아이콘 | +| `destructive` | red-500 | white | 삭제, 위험 액션 | +| `link` | transparent | sky-500 | 텍스트 링크 | + +**크기**: `sm` (h-8), `default` (h-9), `lg` (h-10), `icon` (h-9 w-9) + +### 카드 + +``` +┌─ Card ──────────────────────────────┐ +│ ┌─ CardHeader ──────────────────┐ │ +│ │ CardTitle CardAction │ │ +│ │ CardDescription │ │ +│ └──────────────────────────────┘ │ +│ ┌─ CardContent ─────────────────┐ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +│ ┌─ CardFooter ──────────────────┐ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +└────────────────────────────────────┘ +``` + +- 패딩: `px-6` +- 라운딩: `rounded-lg` (12px) +- 호버: `translateY(-2px)` + 그림자 증가 + +### 데이터 테이블 + +- shadcn/ui Table 컴포넌트 +- 정렬, 필터, 페이지네이션 +- 모바일: 수직 레이아웃 전환 + +### 상태 뱃지 + +| 상태 | 색상 | 텍스트 | +|------|------|--------| +| 출석(submitted) | green-100/green-800 | 제출 완료 | +| 미제출(pending) | zinc-100/zinc-600 | 미제출 | +| 지각(late) | amber-100/amber-800 | 지각 | +| 결석(absent) | red-100/red-800 | 결석 | +| 활성(active) | sky-100/sky-800 | 활성 | +| 휴면(dormant) | zinc-100/zinc-600 | 휴면 | +| 탈퇴(withdrawn) | zinc-50/zinc-400 | 탈퇴 | + +--- + +## 아이콘 + +**lucide-react** 사용. 주요 아이콘: + +| 메뉴 | 아이콘 | +|------|--------| +| 대시보드 | `LayoutDashboard` | +| 글 목록 | `FileText` | +| 랭킹 | `Trophy` | +| 큐레이션 | `Newspaper` | +| 멤버 관리 | `Users` | +| 출석 | `CalendarCheck` | +| 벌금 | `Banknote` | +| 설정 | `Settings` | +| 알림 | `Bell` | +| 다크모드 | `Moon` / `Sun` | +| 프로필 | `User` | + +--- + +## 다크모드 + +- `next-themes` 사용 +- `attribute="class"` (html에 `dark` 클래스 추가) +- `defaultTheme="system"` (시스템 설정 따름) +- CSS 변수 기반 전환 (트랜지션 없음 - `disableTransitionOnChange`) + +--- + +## 반응형 브레이크포인트 + +| 이름 | 너비 | 변경 | +|------|------|------| +| mobile | < 768px | 1열, 사이드바 숨김 | +| tablet | 768px - 1023px | 1열, 축소된 사이드바 | +| desktop | ≥ 1024px | 사이드바 + 메인 컨텐츠 | + +--- + +## 접근성 (a11y) + +- 시맨틱 HTML: `
`, `