diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2839d08 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +node_modules +.next +.git +.gitignore +.DS_Store +*.log +.env +.env.local +.env.*.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.vscode +.idea +*.swp +*.swo +*~ + +# Build outputs +dist +out +build + +# Testing +coverage +.nyc_output + +# Docker files +Dockerfile* +docker-compose* +.dockerignore + +# Scripts and deployment +scripts/ +deploy/ + +# Documentation +README.md +*.md + +# Prisma +prisma/migrations/ + +# Config files +.editorconfig +.eslintrc.json +.prettierrc + +# OS +Thumbs.db + +# Trunk +.trunk + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4e64e6e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,158 @@ +name: Build Docker Image + +on: + push: + branches: [main] + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + outputs: + image-tag: ${{ steps.meta.outputs.tags }} + image-digest: ${{ steps.build.outputs.digest }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Lowercase repository name + id: lowercase + run: | + echo "repo=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.repo }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix={{branch}}-sha-,format=long + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v5 + with: + context: ./services/nextjs + file: ./deploy/docker/Dockerfile.prod + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract SHA tag + id: extract-tag + run: | + # Extract the SHA-based tag from the tags list + TAGS="${{ steps.meta.outputs.tags }}" + echo "All tags:" + echo "$TAGS" + echo "---" + + # Get commit SHA (full 40 characters) + COMMIT_SHA="${{ github.sha }}" + + # Method 1: Extract the full SHA tag from docker/metadata-action output + # docker/metadata-action creates: main-sha- + SHA_TAG=$(echo "$TAGS" | grep -oE 'main-sha-[a-f0-9]{40}' | head -n 1) + + # Method 2: If not found, try to extract any main-sha- tag (fallback) + if [ -z "$SHA_TAG" ]; then + SHA_TAG=$(echo "$TAGS" | grep -oE 'main-sha-[a-f0-9]+' | head -n 1) + if [ -n "$SHA_TAG" ]; then + echo "⚠️ Found SHA tag (may not be full 40 chars): $SHA_TAG" + fi + fi + + # Method 3: Fallback to commit SHA directly (construct the tag) + if [ -z "$SHA_TAG" ]; then + SHA_TAG="main-sha-$COMMIT_SHA" + echo "⚠️ Could not extract from tags, using commit SHA: $SHA_TAG" + fi + + if [ -z "$SHA_TAG" ]; then + echo "❌ ERROR: Failed to extract SHA tag" + exit 1 + fi + + echo "sha-tag=$SHA_TAG" >> $GITHUB_OUTPUT + echo "✅ Extracted SHA tag: $SHA_TAG" + + - name: Update kustomization with new image tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + # Validate that SHA_TAG is not empty + SHA_TAG="${{ steps.extract-tag.outputs.sha-tag }}" + if [ -z "$SHA_TAG" ]; then + echo "❌ ERROR: SHA_TAG is empty, cannot update kustomization" + exit 1 + fi + + echo "📝 Updating kustomization.yaml with tag: $SHA_TAG" + + # Update kustomization.yaml with new image tag + # Handle both cases: newTag: (with value) and newTag: (empty) + sed -i.bak "s|newTag:.*|newTag: $SHA_TAG|" deploy/k8s/overlays/prod/kustomization.yaml + + # Verify the update was successful + if grep -q "newTag: $SHA_TAG" deploy/k8s/overlays/prod/kustomization.yaml; then + echo "✅ Successfully updated kustomization.yaml" + rm -f deploy/k8s/overlays/prod/kustomization.yaml.bak + else + echo "❌ ERROR: Failed to update kustomization.yaml" + cat deploy/k8s/overlays/prod/kustomization.yaml + exit 1 + fi + + # Commit and push if there are changes + if git diff --quiet; then + echo "No changes to commit" + else + git add deploy/k8s/overlays/prod/kustomization.yaml + git commit -m "Update image to $SHA_TAG" + git push + echo "✅ Kustomization updated with new image tag: $SHA_TAG" + fi + + - name: Display image information + run: | + echo "✅ Image built and pushed successfully!" + echo "📦 Image tags:" + echo "${{ steps.meta.outputs.tags }}" + echo "🔖 SHA tag: ${{ steps.extract-tag.outputs.sha-tag }}" + echo "🔖 Digest: ${{ steps.build.outputs.digest }}" + echo "" + echo "🚀 Kustomization updated with new image tag" + echo " ArgoCD will automatically detect and deploy this new image" + echo " Monitor deployment at your ArgoCD dashboard" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06d7e61 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + lint-and-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: services/nextjs/package-lock.json + + - name: Install dependencies + working-directory: services/nextjs + run: npm ci + + - name: Run ESLint + working-directory: services/nextjs + run: npm run lint + + - name: Build Next.js application + working-directory: services/nextjs + run: npm run build + env: + NEXT_TELEMETRY_DISABLED: 1 + + - name: Check build output + working-directory: services/nextjs + run: | + if [ ! -d ".next" ]; then + echo "Build failed: .next directory not found" + exit 1 + fi + echo "✅ Build completed successfully" + diff --git a/.gitignore b/.gitignore index ff9462d..ecddc29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules -/.pnp +node_modules +services/nextjs/node_modules +.pnp .pnp.* .yarn/* !.yarn/patches @@ -11,14 +12,15 @@ !.yarn/versions # testing -/coverage +coverage +services/nextjs/coverage # next.js -/.next/ -/out/ +services/nextjs/.next/ +services/nextjs/out/ # production -/build +services/nextjs/build # misc .DS_Store @@ -30,8 +32,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files .env* +!.env.example # vercel .vercel @@ -40,4 +43,21 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -/src/ +# prisma +services/nextjs/prisma/dev.db +services/nextjs/prisma/dev.db-journal +services/nextjs/prisma/*.db +services/nextjs/prisma/*.db-journal + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +Thumbs.db + +# trunk +.trunk diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index a4a3917..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Home() { - return ( -
- This page will be filled with Todo App -
- ); -} diff --git a/deploy/docker/Dockerfile.dev b/deploy/docker/Dockerfile.dev new file mode 100644 index 0000000..54f8189 --- /dev/null +++ b/deploy/docker/Dockerfile.dev @@ -0,0 +1,30 @@ +# trunk-ignore-all(checkov/CKV_DOCKER_3) +FROM node:20-alpine AS base + +# Install dependencies for development +RUN apk add --no-cache libc6-compat curl + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install all dependencies (including dev dependencies) +RUN npm ci + +# Copy source code +COPY . . + +# Generate Prisma Client +RUN npx prisma generate + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 + +# Default command (can be overridden in docker-compose) +CMD ["npm", "run", "dev"] + diff --git a/deploy/docker/Dockerfile.prod b/deploy/docker/Dockerfile.prod new file mode 100644 index 0000000..9943b2b --- /dev/null +++ b/deploy/docker/Dockerfile.prod @@ -0,0 +1,65 @@ +# Multi-stage build for Next.js application +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json* ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Generate Prisma Client +RUN npx prisma generate + +# Build the application +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +RUN apk add --no-cache curl + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs + +# Copy built application +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next && chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Copy Prisma files +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 + +CMD ["node", "server.js"] + diff --git a/deploy/docker/docker-compose.dev.yml b/deploy/docker/docker-compose.dev.yml new file mode 100644 index 0000000..28b0028 --- /dev/null +++ b/deploy/docker/docker-compose.dev.yml @@ -0,0 +1,64 @@ +services: + # Next.js Application (Development) - Using External Database (same as Jotion) + app: + image: todo-app-dev + build: + context: ../../services/nextjs + dockerfile: ../../deploy/docker/Dockerfile.dev + container_name: todo-app-dev + restart: unless-stopped + labels: + kompose.namespace: todo-dev + ports: + - "3002:3000" + env_file: + - ../../.env + environment: + - NODE_ENV=development + networks: + - todo-network-dev + volumes: + - ../../services/nextjs:/app + - /app/node_modules + - /app/.next + - app_logs_dev:/app/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + command: > + sh -lc "npx prisma db push --skip-generate && npm run dev" + + # Prisma Studio - Connects to External Database + prisma-studio: + image: todo-app-dev + container_name: todo-prisma-studio + restart: unless-stopped + labels: + kompose.namespace: todo-dev + ports: + - "5556:5555" + env_file: + - ../../.env + environment: + - NODE_ENV=development + networks: + - todo-network-dev + volumes: + - ../../services/nextjs:/app + - /app/node_modules + command: npx prisma studio --port 5555 --hostname 0.0.0.0 + +volumes: + # Named volumes for data persistence + app_logs_dev: + driver: local + +networks: + todo-network-dev: + driver: bridge + ipam: + config: + - subnet: 172.22.0.0/16 diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..b60d844 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,41 @@ +services: + # Next.js Application - Using External Database (same as Jotion) + app: + image: todo-app + build: + context: ../../services/nextjs + dockerfile: ../../deploy/docker/Dockerfile.prod + container_name: todo-app + restart: unless-stopped + labels: + kompose.namespace: todo + ports: + - 3002:3000 + env_file: + - ../../.env + environment: + - NODE_ENV=production + networks: + - todo-network + volumes: + - app_logs:/app/logs + healthcheck: + test: [CMD, curl, -f, http://localhost:3000/api/health] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + command: > + sh -lc "npx prisma db push --skip-generate && node server.js" + +volumes: + # Named volumes for data persistence + app_logs: + driver: local + +networks: + todo-network: + driver: bridge + ipam: + config: + - subnet: 172.21.0.0/16 diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml new file mode 100644 index 0000000..169d3e4 --- /dev/null +++ b/deploy/k8s/base/deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: todo-app + labels: + app: todo-app +spec: + replicas: 1 + selector: + matchLabels: + app: todo-app + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + template: + metadata: + labels: + app: todo-app + spec: + containers: + - name: todo-app + image: ghcr.io/mayne0213/todo:latest + imagePullPolicy: Always + ports: + - containerPort: 3000 + protocol: TCP + env: + - name: NODE_ENV + value: production + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: todo-secrets + key: database-url + resources: + requests: + memory: "100Mi" + cpu: "50m" + limits: + memory: "200Mi" + cpu: "150m" + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + restartPolicy: Always + diff --git a/deploy/k8s/base/kustomization.yaml b/deploy/k8s/base/kustomization.yaml new file mode 100644 index 0000000..034c27b --- /dev/null +++ b/deploy/k8s/base/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - deployment.yaml + - service.yaml + +commonLabels: + app.kubernetes.io/name: todo + app.kubernetes.io/component: web + +images: + - name: ghcr.io/mayne0213/todo + newTag: latest + diff --git a/deploy/k8s/base/service.yaml b/deploy/k8s/base/service.yaml new file mode 100644 index 0000000..6da9d01 --- /dev/null +++ b/deploy/k8s/base/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: todo-service + labels: + app: todo-app +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: 3000 + protocol: TCP + selector: + app: todo-app + diff --git a/deploy/k8s/overlays/prod/deployment-patch.yaml b/deploy/k8s/overlays/prod/deployment-patch.yaml new file mode 100644 index 0000000..7f3a972 --- /dev/null +++ b/deploy/k8s/overlays/prod/deployment-patch.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: todo-app + labels: + environment: production +spec: + replicas: 1 + template: + spec: + containers: + - name: todo-app + resources: + requests: + memory: "120Mi" + cpu: "50m" + limits: + memory: "230Mi" + cpu: "150m" + diff --git a/deploy/k8s/overlays/prod/kustomization.yaml b/deploy/k8s/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..bbe482b --- /dev/null +++ b/deploy/k8s/overlays/prod/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: todo + +resources: + - ../../base + - resourcequota.yaml + +commonLabels: + environment: production + +# 이미지 태그 설정 +images: + - name: ghcr.io/mayne0213/todo + newTag: latest + +patchesStrategicMerge: + - deployment-patch.yaml + diff --git a/deploy/k8s/overlays/prod/resourcequota.yaml b/deploy/k8s/overlays/prod/resourcequota.yaml new file mode 100644 index 0000000..d47bd99 --- /dev/null +++ b/deploy/k8s/overlays/prod/resourcequota.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ResourceQuota +metadata: + name: todo-quota + namespace: todo +spec: + hard: + requests.memory: "150Mi" + requests.cpu: "100m" + limits.memory: "250Mi" + limits.cpu: "200m" + pods: "2" + diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..2289336 --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,174 @@ +#!/bin/bash + +# Todo 스크립트 공통 유틸리티 함수들 +# 모든 Todo 스크립트에서 사용할 수 있는 공통 기능들을 정의 + +set -e +# shopt -s inherit_errexit + +# 공통 스크립트의 절대 경로 기반 디렉토리 상수 +# 함수 호출 컨텍스트에 따라 BASH_SOURCE 해석이 달라질 수 있으므로 +# 로드 시점에 고정해 신뢰 가능한 루트를 계산한다 +TODO_SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TODO_ROOT="$(dirname "${TODO_SCRIPTS_DIR}")" + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 로깅 함수들 +log_info() { + echo -e "${GREEN}[INFO]${NC} ${1}" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} ${1}" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} ${1}" +} + +log_debug() { + echo -e "${BLUE}[DEBUG]${NC} ${1}" +} + +# 경로 계산 함수 +get_todo_root() { + # 로드 시점에 고정된 루트 경로 반환 + echo "${TODO_ROOT}" +} + +get_mayne_root() { + # todo 루트를 기준으로 mayne 루트 경로 계산 + local todo_root + todo_root="$(get_todo_root)" + dirname "$(dirname "${todo_root}")" +} + +# 확인 함수 +confirm_action() { + local message="${1}" + local default="${2:-N}" + + if [[ "${default}" == "Y" ]]; then + read -p "${message} (Y/n): " -n 1 -r + echo + if [[ ${REPLY} =~ ^[Nn]$ ]]; then + return 1 + fi + return 0 + else + read -p "${message} (y/N): " -n 1 -r + echo + if [[ ${REPLY} =~ ^[Yy]$ ]]; then + return 0 + fi + return 1 + fi +} + +# 필수 디렉토리 확인 +check_required_dirs() { + local todo_root + todo_root="$(get_todo_root)" + local dirs=("${@}") + + for dir in "${dirs[@]}"; do + if [[ ! -d "${todo_root}/${dir}" ]]; then + log_error "필수 디렉토리가 없습니다: ${dir}" + exit 1 + fi + done +} + +# 필수 파일 확인 +check_required_files() { + local todo_root + todo_root="$(get_todo_root)" + local files=("${@}") + + for file in "${files[@]}"; do + if [[ ! -f "${todo_root}/${file}" ]]; then + log_error "필수 파일이 없습니다: ${file}" + exit 1 + fi + done +} + +# Docker 관련 유틸리티 +docker_cleanup_todo() { + log_info "Todo 관련 Docker 리소스 정리 중..." + + # 컨테이너 중지 및 삭제 + docker-compose -p todo -f deploy/docker/docker-compose.yml down --remove-orphans 2>/dev/null || true + docker-compose -p todo -f deploy/docker/docker-compose.dev.yml down --remove-orphans 2>/dev/null || true + docker ps -aq --filter "name=todo" | xargs -r docker rm -f 2>/dev/null || true + + # 이미지 삭제 + docker images --filter "reference=todo*" -q | xargs -r docker rmi -f 2>/dev/null || true + docker images --filter "reference=*todo*" -q | xargs -r docker rmi -f 2>/dev/null || true + + # 볼륨 삭제 + docker volume ls -q --filter "name=todo" | xargs -r docker volume rm -f 2>/dev/null || true + + # 시스템 정리 + docker system prune -f + + log_info "Docker 리소스 정리 완료" +} + +# 환경 변수 로드 +load_env_file() { + local todo_root + todo_root="$(get_todo_root)" + local env_file="${todo_root}/.env" + + if [[ -f "${env_file}" ]]; then + log_info "환경 변수 파일 로드: ${env_file}" + # shellcheck source=/dev/null + source "${env_file}" + return 0 + else + log_warn "환경 변수 파일이 없습니다: ${env_file}" + return 1 + fi +} + +# 에러 처리 +handle_error() { + local exit_code=$? + log_error "스크립트 실행 중 오류가 발생했습니다 (종료 코드: ${exit_code})" + exit "${exit_code}" +} + +# 스크립트 시작 시 공통 설정 +setup_script() { + # 에러 발생 시 자동으로 handle_error 함수 호출 + trap handle_error ERR + + # 스크립트 디렉토리로 이동 + local todo_root + todo_root="$(get_todo_root)" + cd "${todo_root}" + + log_info "스크립트 시작: $(basename "${BASH_SOURCE[1]}")" +} + +# 스크립트 종료 시 정리 +cleanup_script() { + local exit_code=$? + if [[ "${exit_code}" -eq 0 ]]; then + log_info "스크립트 완료: $(basename "${BASH_SOURCE[1]}")" + else + log_error "스크립트 실패: $(basename "${BASH_SOURCE[1]}") (종료 코드: ${exit_code})" + fi + exit "${exit_code}" +} + +# 스크립트 종료 시 정리 함수 등록 +trap cleanup_script EXIT + diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100755 index 0000000..81995e2 --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# Todo Docker 빌드 및 실행 스크립트 +# 공통 유틸리티 함수 로드 +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# 스크립트 설정 +setup_script + +log_info "🚀 Todo Docker 빌드 및 실행 시작..." + +# 필수 디렉토리 및 파일 확인 +log_info "📁 폴더 구조 확인 중..." +check_required_dirs "services/nextjs" "deploy/docker" + +log_info "📄 필수 파일 확인 중..." +check_required_files "deploy/docker/docker-compose.yml" "deploy/docker/docker-compose.dev.yml" "deploy/docker/Dockerfile.prod" "deploy/docker/Dockerfile.dev" + +log_info "✅ 폴더 구조 및 필수 파일 확인 완료!" + +# 환경 선택 +echo "" +log_info "🎯 실행할 환경을 선택하세요:" +echo "1) 개발 환경 (Development)" +echo "2) 프로덕션 환경 (Production)" +echo "3) 빌드만 (Build Only)" +read -p "선택 (1-3): " -n 1 -r +echo + +case ${REPLY} in + 1) + log_info "🔧 개발 환경 빌드 및 실행 중..." + cd deploy/docker + docker-compose -p todo -f docker-compose.dev.yml build --no-cache + docker-compose -p todo -f docker-compose.dev.yml up -d + TODO_ROOT=$(get_todo_root) + cd "${TODO_ROOT}" + ENV_TYPE="development" + COMPOSE_FILE_PATH="deploy/docker/docker-compose.dev.yml" + ;; + 2) + log_info "🏭 프로덕션 환경 빌드 및 실행 중..." + cd deploy/docker + docker-compose -p todo -f docker-compose.yml build --no-cache + docker-compose -p todo -f docker-compose.yml up -d + TODO_ROOT=$(get_todo_root) + cd "${TODO_ROOT}" + ENV_TYPE="production" + COMPOSE_FILE_PATH="deploy/docker/docker-compose.yml" + ;; + 3) + log_info "🔨 이미지 빌드만 실행 중..." + cd deploy/docker + log_info " - 개발 이미지 빌드 중..." + docker-compose -p todo -f docker-compose.dev.yml build --no-cache + log_info " - 프로덕션 이미지 빌드 중..." + docker-compose -p todo -f docker-compose.yml build --no-cache + TODO_ROOT=$(get_todo_root) + cd "${TODO_ROOT}" + log_info "✅ 빌드 완료! 실행하려면 다시 이 스크립트를 실행하고 환경을 선택하세요." + exit 0 + ;; + *) + log_error "잘못된 선택입니다." + exit 1 + ;; +esac + +# 서비스 상태 확인 +echo "" +log_info "⏳ 서비스 시작 대기 중..." +sleep 10 + +echo "" +log_info "📊 서비스 상태 확인:" +docker-compose -p todo -f "${COMPOSE_FILE_PATH}" ps + +echo "" +log_info "🔍 컨테이너 로그 확인:" +echo " - 애플리케이션 로그: docker-compose -p todo -f ${COMPOSE_FILE_PATH} logs -f app" +if [[ "${ENV_TYPE}" = "development" ]]; then + echo " - Prisma Studio 로그: docker-compose -p todo -f ${COMPOSE_FILE_PATH} logs -f prisma-studio" +fi + +echo "" +log_info "🌐 접속 URL:" +if [[ "${ENV_TYPE}" = "development" ]]; then + echo " - 애플리케이션: http://localhost:3002" + echo " - Prisma Studio: http://localhost:5556" + echo " - 데이터베이스: 외부 DB (Jotion과 동일)" +else + echo " - 애플리케이션: http://localhost:3000" + echo " - 데이터베이스: 외부 DB (Jotion과 동일)" +fi + +echo "" +log_info "✅ Docker 빌드 및 실행 완료!" +echo "" +log_info "📋 유용한 명령어:" +echo " - 서비스 중지: docker-compose -p todo -f ${COMPOSE_FILE_PATH} down" +echo " - 로그 확인: docker-compose -p todo -f ${COMPOSE_FILE_PATH} logs -f" +echo " - 서비스 재시작: docker-compose -p todo -f ${COMPOSE_FILE_PATH} restart" +echo " - Prisma 마이그레이션: docker-compose -p todo -f ${COMPOSE_FILE_PATH} exec app npx prisma migrate deploy" +echo " - Prisma 스키마 동기화: docker-compose -p todo -f ${COMPOSE_FILE_PATH} exec app npx prisma db push" +echo "" + diff --git a/scripts/docker-cleanup.sh b/scripts/docker-cleanup.sh new file mode 100755 index 0000000..22d6014 --- /dev/null +++ b/scripts/docker-cleanup.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Todo Docker 리소스 정리 스크립트 +# 공통 유틸리티 함수 로드 +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# 스크립트 설정 +setup_script + +log_info "🧹 Todo Docker 리소스 정리 시작..." +log_info "💡 참고: 외부 DB를 사용하므로 데이터베이스 데이터는 보존됩니다." + +# 현재 실행 중인 Todo 관련 컨테이너 확인 +log_info "📋 현재 실행 중인 Todo 관련 컨테이너:" +docker ps -a --filter "name=todo" --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" + +echo "" +if confirm_action "⚠️ 모든 Todo 관련 컨테이너, 이미지, 볼륨을 삭제하시겠습니까?"; then + : # 계속 진행 +else + log_info "작업이 취소되었습니다." + exit 0 +fi + +echo "" +log_info "🛑 컨테이너 중지 및 삭제 중..." + +# Docker 정리 실행 +docker_cleanup_todo + +log_info "✅ 정리 완료!" +echo "" +log_info "📊 정리된 리소스:" +echo " - Todo 관련 컨테이너: 삭제됨" +echo " - Todo 관련 이미지: 삭제됨" +echo " - Todo 관련 볼륨: 삭제됨 (로그 파일)" +echo " - 사용하지 않는 Docker 리소스: 정리됨" +echo "" +log_info "💾 데이터베이스: 외부 DB에 안전하게 보존됨 (Jotion과 동일)" +echo "" +log_info "🚀 이제 './scripts/docker-build.sh' 스크립트를 실행하여 재빌드하세요!" + diff --git a/services/nextjs/.gitignore b/services/nextjs/.gitignore new file mode 100644 index 0000000..d08ad59 --- /dev/null +++ b/services/nextjs/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/README.md b/services/nextjs/README.md similarity index 100% rename from README.md rename to services/nextjs/README.md diff --git a/services/nextjs/app/api/health/route.ts b/services/nextjs/app/api/health/route.ts new file mode 100644 index 0000000..b424263 --- /dev/null +++ b/services/nextjs/app/api/health/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/shared/lib/prisma' + +export async function GET() { + try { + // 데이터베이스 연결 확인 + await prisma.$queryRaw`SELECT 1` + + return NextResponse.json({ + status: 'ok', + timestamp: new Date().toISOString(), + database: 'connected' + }) + } catch { + return NextResponse.json( + { + status: 'error', + timestamp: new Date().toISOString(), + error: 'Database connection failed' + }, + { status: 503 } + ) + } +} + diff --git a/services/nextjs/app/api/todos/[id]/route.ts b/services/nextjs/app/api/todos/[id]/route.ts new file mode 100644 index 0000000..7c2a97b --- /dev/null +++ b/services/nextjs/app/api/todos/[id]/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/shared/lib/prisma' +import { corsHeaders, handleCorsPreFlight } from '@/shared/lib/cors' +import type { Prisma } from '@prisma/client' + +// OPTIONS - CORS preflight 처리 +export async function OPTIONS() { + return handleCorsPreFlight() +} + +// PUT - TODO 업데이트 +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: idParam } = await params + const id = parseInt(idParam) + const body = await request.json() + + const updateData: Prisma.TodoUpdateInput = {} + if (body.title !== undefined) updateData.title = body.title + if (body.description !== undefined) updateData.description = body.description + if (body.completed !== undefined) updateData.completed = body.completed + if (body.priority !== undefined) updateData.priority = body.priority + + const todo = await prisma.todo.update({ + where: { id }, + data: updateData, + }) + + const response = NextResponse.json({ success: true, data: todo }) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } catch (error) { + console.error('Error updating todo:', error) + const response = NextResponse.json( + { success: false, error: 'Failed to update todo' }, + { status: 500 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } +} + +// DELETE - TODO 삭제 +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: idParam } = await params + const id = parseInt(idParam) + await prisma.todo.delete({ + where: { id }, + }) + + const response = NextResponse.json({ success: true, message: 'Todo deleted' }) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } catch (error) { + console.error('Error deleting todo:', error) + const response = NextResponse.json( + { success: false, error: 'Failed to delete todo' }, + { status: 500 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } +} + diff --git a/services/nextjs/app/api/todos/route.ts b/services/nextjs/app/api/todos/route.ts new file mode 100644 index 0000000..958fea5 --- /dev/null +++ b/services/nextjs/app/api/todos/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/shared/lib/prisma' +import { corsHeaders, handleCorsPreFlight } from '@/shared/lib/cors' + +// OPTIONS - CORS preflight 처리 +export async function OPTIONS() { + return handleCorsPreFlight() +} + +// GET - 모든 TODO 가져오기 +export async function GET() { + try { + const todos = await prisma.todo.findMany({ + orderBy: { createdAt: 'desc' }, + }) + const response = NextResponse.json({ success: true, data: todos }) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } catch (error) { + console.error('Error fetching todos:', error) + const response = NextResponse.json( + { success: false, error: 'Failed to fetch todos' }, + { status: 500 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } +} + +// POST - TODO 생성 +export async function POST(request: Request) { + try { + const body = await request.json() + + const todo = await prisma.todo.create({ + data: { + title: body.title, + description: body.description || null, + completed: body.completed || false, + priority: body.priority || 'medium', + }, + }) + + const response = NextResponse.json({ success: true, data: todo }, { status: 201 }) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } catch (error) { + console.error('Error creating todo:', error) + const response = NextResponse.json( + { success: false, error: 'Failed to create todo' }, + { status: 500 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } +} + diff --git a/app/favicon.ico b/services/nextjs/app/favicon.ico similarity index 100% rename from app/favicon.ico rename to services/nextjs/app/favicon.ico diff --git a/app/globals.css b/services/nextjs/app/globals.css similarity index 99% rename from app/globals.css rename to services/nextjs/app/globals.css index 4ecb0b4..96f5841 100644 --- a/app/globals.css +++ b/services/nextjs/app/globals.css @@ -93,7 +93,6 @@ --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); } - :root { --radius: 0.625rem; --background: oklch(1 0 0); @@ -170,4 +169,4 @@ body { @apply bg-background text-foreground; } -} +} \ No newline at end of file diff --git a/app/layout.tsx b/services/nextjs/app/layout.tsx similarity index 100% rename from app/layout.tsx rename to services/nextjs/app/layout.tsx diff --git a/services/nextjs/app/page.tsx b/services/nextjs/app/page.tsx new file mode 100644 index 0000000..86178dd --- /dev/null +++ b/services/nextjs/app/page.tsx @@ -0,0 +1,5 @@ +import { TodoApp } from '@/src/widgets/todo-app' + +export default function Home() { + return +} diff --git a/services/nextjs/app/tailwind/page.tsx b/services/nextjs/app/tailwind/page.tsx new file mode 100644 index 0000000..eae0c66 --- /dev/null +++ b/services/nextjs/app/tailwind/page.tsx @@ -0,0 +1,184 @@ +export default function Home() { + return ( +
+

Tailwind CSS 학습 자료

+ +
+ + {/* 색상 학습 */} +
+

1. 글씨 색상 (Text Color)

+
+ {/* 여기에는 빨간색 글씨가 와야합니다 */} +

이 텍스트는 빨간색이어야 합니다

+ + {/* 여기에는 파란색 글씨가 와야합니다 */} +

이 텍스트는 파란색이어야 합니다

+ + {/* 여기에는 초록색 글씨가 와야합니다 */} +

이 텍스트는 초록색이어야 합니다

+
+
+ + {/* 넓이와 높이 학습 */} +
+

2. 넓이와 높이 (Width & Height)

+
+ {/* 여기에는 넓이 200px, 높이 100px이 와야합니다 */} +
+ + {/* 여기에는 넓이는 전체의 50%, 높이 150px이 와야합니다 */} +
+ + {/* 여기에는 넓이와 높이가 모두 80px인 정사각형이 와야합니다 */} +
+
+
+ + {/* 그림자 학습 */} +
+

3. 그림자 (Shadow)

+
+ {/* 여기에는 작은 그림자가 와야합니다 */} +
작은 그림자
+ + {/* 여기에는 중간 크기 그림자가 와야합니다 */} +
중간 그림자
+ + {/* 여기에는 큰 그림자가 와야합니다 */} +
큰 그림자
+
+
+ + {/* 글씨 크기 학습 */} +
+

4. 글씨 크기 (Text Size)

+
+ {/* 여기에는 매우 작은 글씨(12px)가 와야합니다 */} +

작은 글씨

+ + {/* 여기에는 기본 글씨 크기가 와야합니다 */} +

기본 글씨

+ + {/* 여기에는 큰 글씨(24px)가 와야합니다 */} +

큰 글씨

+ + {/* 여기에는 매우 큰 글씨(36px)가 와야합니다 */} +

매우 큰 글씨

+
+
+ + {/* Flex 학습 */} +
+

5. Flex

+
+ {/* 여기에는 가로로 배치되는 flex container가 와야합니다 */} +
+
Item 1
+
Item 2
+
Item 3
+
+ + {/* 여기에는 세로로 배치되는 flex container가 와야합니다 */} +
+
Item A
+
Item B
+
Item C
+
+ + {/* 여기에는 가로로 배치되면서 아이템들이 가운데 정렬되는 flex container가 와야합니다 */} +
+
중앙
+
정렬
+
+
+
+ + {/* 정렬 학습 */} +
+

6. 정렬 (Alignment)

+
+ {/* 여기에는 텍스트가 왼쪽 정렬되어야 합니다 */} +

왼쪽 정렬된 텍스트입니다

+ + {/* 여기에는 텍스트가 가운데 정렬되어야 합니다 */} +

가운데 정렬된 텍스트입니다

+ + {/* 여기에는 텍스트가 오른쪽 정렬되어야 합니다 */} +

오른쪽 정렬된 텍스트입니다

+
+
+ + {/* 둥근 모서리 학습 */} +
+

7. 둥근 모서리 (Rounded Corners)

+
+ {/* 여기에는 약간 둥근 모서리가 와야합니다 */} +
약간 둥근 모서리
+ + {/* 여기에는 중간 정도 둥근 모서리가 와야합니다 */} +
중간 둥근 모서리
+ + {/* 여기에는 매우 둥근 모서리(원에 가까운)가 와야합니다 */} +
매우 둥근 모서리
+ + {/* 여기에는 완전한 원이 와야합니다 (넓이와 높이가 같고 매우 둥근 모서리) */} +
+
+
+ + {/* 마진 학습 */} +
+

8. 마진 (Margin)

+
+ {/* 여기에는 위쪽에 마진이 4단위(16px) 와야합니다 */} +
위쪽 마진 16px
+ + {/* 여기에는 좌우에 마진이 각각 8단위(32px) 와야합니다 */} +
좌우 마진 32px
+ + {/* 여기에는 모든 방향에 마진이 12단위(48px) 와야합니다 */} +
모든 방향 마진 48px
+
+
+ + {/* 패딩 학습 */} +
+

9. 패딩 (Padding)

+
+ {/* 여기에는 패딩이 2단위(8px) 와야합니다 */} +
작은 패딩 (8px)
+ + {/* 여기에는 패딩이 6단위(24px) 와야합니다 */} +
중간 패딩 (24px)
+ + {/* 여기에는 패딩이 12단위(48px) 와야합니다 */} +
큰 패딩 (48px)
+ + {/* 여기에는 위아래 패딩 4단위, 좌우 패딩 8단위가 와야합니다 */} +
세로 16px, 가로 32px 패딩
+
+
+ + {/* 종합 예제 */} +
+

10. 종합 예제 (모든 개념 사용)

+ {/* 여기에는 다음 조건들이 모두 적용되어야 합니다: + - 넓이 300px, 높이 200px + - 파란색 배경 + - 가운데 정렬된 텍스트(행,열 전부 정렬되어있어야 합니다.) + - 큰 그림자 + - 둥근 모서리 + - 모든 방향 패딩 24px + - 위쪽 마진 16px + - 흰색 텍스트, 큰 글씨 + */} +
+ 종합 예제 박스 +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/components.json b/services/nextjs/components.json similarity index 100% rename from components.json rename to services/nextjs/components.json diff --git a/eslint.config.mjs b/services/nextjs/eslint.config.mjs similarity index 100% rename from eslint.config.mjs rename to services/nextjs/eslint.config.mjs diff --git a/lib/utils.ts b/services/nextjs/lib/utils.ts similarity index 100% rename from lib/utils.ts rename to services/nextjs/lib/utils.ts diff --git a/next.config.ts b/services/nextjs/next.config.ts similarity index 61% rename from next.config.ts rename to services/nextjs/next.config.ts index e9ffa30..f70ce7d 100644 --- a/next.config.ts +++ b/services/nextjs/next.config.ts @@ -1,7 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Enable standalone output for Docker + output: 'standalone', }; export default nextConfig; diff --git a/package-lock.json b/services/nextjs/package-lock.json similarity index 94% rename from package-lock.json rename to services/nextjs/package-lock.json index 49fb2c8..8ce6ddd 100644 --- a/package-lock.json +++ b/services/nextjs/package-lock.json @@ -8,6 +8,7 @@ "name": "todo_app", "version": "0.1.0", "dependencies": { + "@prisma/client": "^6.19.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", @@ -29,6 +30,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.4", + "prisma": "^6.19.0", "tailwindcss": "^4", "typescript": "^5" } @@ -940,6 +942,84 @@ "node": ">=12.4.0" } }, + "node_modules/@prisma/client": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", + "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", + "hasInstallScript": true, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", + "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", + "devOptional": true, + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", + "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", + "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/fetch-engine": "6.19.0", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", + "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", + "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", + "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.19.0" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -1474,6 +1554,12 @@ "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "devOptional": true + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1785,6 +1871,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1794,6 +1881,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "devOptional": true, + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -1841,6 +1929,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -2326,6 +2415,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2627,6 +2717,34 @@ "node": ">=8" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2718,6 +2836,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2727,6 +2860,15 @@ "node": ">=18" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2775,6 +2917,21 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "devOptional": true + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2875,6 +3032,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2909,6 +3075,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true + }, "node_modules/detect-libc": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", @@ -2935,6 +3113,18 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2949,12 +3139,31 @@ "node": ">= 0.4" } }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -3154,6 +3363,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3321,6 +3531,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3553,6 +3764,34 @@ "node": ">=0.10.0" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "devOptional": true + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3794,6 +4033,23 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4401,7 +4657,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", - "dev": true, + "devOptional": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -4982,6 +5238,31 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true + }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "devOptional": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5097,6 +5378,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5197,6 +5484,18 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5214,6 +5513,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5260,6 +5570,32 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", + "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", + "devOptional": true, + "hasInstallScript": true, + "peer": true, + "dependencies": { + "@prisma/config": "6.19.0", + "@prisma/engines": "6.19.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5280,6 +5616,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5300,10 +5652,21 @@ } ] }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5312,6 +5675,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5391,6 +5755,19 @@ } } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5967,7 +6344,8 @@ "node_modules/tailwindcss": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", - "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==" + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -6006,6 +6384,15 @@ "node": ">=18" } }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6044,6 +6431,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -6182,7 +6570,8 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, + "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/services/nextjs/package.json similarity index 93% rename from package.json rename to services/nextjs/package.json index ae43a8d..7022074 100644 --- a/package.json +++ b/services/nextjs/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@prisma/client": "^6.19.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", @@ -30,6 +31,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.4", + "prisma": "^6.19.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/postcss.config.mjs b/services/nextjs/postcss.config.mjs similarity index 100% rename from postcss.config.mjs rename to services/nextjs/postcss.config.mjs diff --git a/services/nextjs/prisma/schema.prisma b/services/nextjs/prisma/schema.prisma new file mode 100644 index 0000000..24d5750 --- /dev/null +++ b/services/nextjs/prisma/schema.prisma @@ -0,0 +1,22 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model Todo { + id Int @id @default(autoincrement()) + title String + description String? + completed Boolean @default(false) + priority String @default("medium") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + diff --git a/public/file.svg b/services/nextjs/public/file.svg similarity index 100% rename from public/file.svg rename to services/nextjs/public/file.svg diff --git a/public/globe.svg b/services/nextjs/public/globe.svg similarity index 100% rename from public/globe.svg rename to services/nextjs/public/globe.svg diff --git a/public/next.svg b/services/nextjs/public/next.svg similarity index 100% rename from public/next.svg rename to services/nextjs/public/next.svg diff --git a/public/vercel.svg b/services/nextjs/public/vercel.svg similarity index 100% rename from public/vercel.svg rename to services/nextjs/public/vercel.svg diff --git a/public/window.svg b/services/nextjs/public/window.svg similarity index 100% rename from public/window.svg rename to services/nextjs/public/window.svg diff --git a/services/nextjs/src/entities/todo/api/client.ts b/services/nextjs/src/entities/todo/api/client.ts new file mode 100644 index 0000000..0156f44 --- /dev/null +++ b/services/nextjs/src/entities/todo/api/client.ts @@ -0,0 +1,166 @@ +import { API_ENDPOINTS } from '../../../shared/api/config' +import type { Todo, CreateTodoData, UpdateTodoData } from '../model/types' + +// API 응답 타입 +interface ApiTodo { + id: number + title: string + description?: string | null + completed: boolean + priority: string + createdAt: string + updatedAt: string +} + +interface ApiResponse { + success: boolean + data?: T + error?: string +} + +// 백엔드 Todo를 프론트엔드 Todo로 변환 +const mapApiTodoToTodo = (apiTodo: ApiTodo): Todo => { + return { + id: String(apiTodo.id), + title: apiTodo.title, + description: apiTodo.description || undefined, + completed: apiTodo.completed, + priority: (apiTodo.priority || 'medium') as 'low' | 'medium' | 'high', + createdAt: new Date(apiTodo.createdAt), + updatedAt: new Date(apiTodo.updatedAt), + } +} + +// 프론트엔드 Todo를 백엔드 형식으로 변환 +const mapTodoToApiTodo = (todo: CreateTodoData | UpdateTodoData): Partial => { + const apiTodo: Partial = { + title: todo.title, + description: todo.description || null, + } + + if ('completed' in todo) { + apiTodo.completed = todo.completed + } + + if ('priority' in todo && todo.priority) { + apiTodo.priority = todo.priority + } + + return apiTodo +} + +/** + * 모든 TODO 가져오기 + */ +export const fetchTodos = async (): Promise => { + try { + const response = await fetch(API_ENDPOINTS.todos, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiResponse = await response.json() + + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch todos') + } + + return result.data.map(mapApiTodoToTodo) + } catch (error) { + console.error('Error fetching todos:', error) + throw error + } +} + +/** + * TODO 생성 + */ +export const createTodo = async (data: CreateTodoData): Promise => { + try { + const response = await fetch(API_ENDPOINTS.todos, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(mapTodoToApiTodo(data)), + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiResponse = await response.json() + + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to create todo') + } + + return mapApiTodoToTodo(result.data) + } catch (error) { + console.error('Error creating todo:', error) + throw error + } +} + +/** + * TODO 업데이트 + */ +export const updateTodo = async (id: string, data: UpdateTodoData): Promise => { + try { + const response = await fetch(`${API_ENDPOINTS.todos}/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(mapTodoToApiTodo(data)), + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiResponse = await response.json() + + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to update todo') + } + + return mapApiTodoToTodo(result.data) + } catch (error) { + console.error('Error updating todo:', error) + throw error + } +} + +/** + * TODO 삭제 + */ +export const deleteTodo = async (id: string): Promise => { + try { + const response = await fetch(`${API_ENDPOINTS.todos}/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiResponse = await response.json() + + if (!result.success) { + throw new Error(result.error || 'Failed to delete todo') + } + } catch (error) { + console.error('Error deleting todo:', error) + throw error + } +} diff --git a/services/nextjs/src/entities/todo/api/index.ts b/services/nextjs/src/entities/todo/api/index.ts new file mode 100644 index 0000000..bbc98f5 --- /dev/null +++ b/services/nextjs/src/entities/todo/api/index.ts @@ -0,0 +1,2 @@ +export * from './client' + diff --git a/services/nextjs/src/entities/todo/index.ts b/services/nextjs/src/entities/todo/index.ts new file mode 100644 index 0000000..551001f --- /dev/null +++ b/services/nextjs/src/entities/todo/index.ts @@ -0,0 +1,2 @@ +export * from './model' +export * from './ui' diff --git a/services/nextjs/src/entities/todo/model/constants.ts b/services/nextjs/src/entities/todo/model/constants.ts new file mode 100644 index 0000000..46ca3d1 --- /dev/null +++ b/services/nextjs/src/entities/todo/model/constants.ts @@ -0,0 +1,19 @@ +import { Todo } from './types' + +export const PRIORITY_LABELS: Record = { + low: '낮음', + medium: '보통', + high: '높음' +} + +export const PRIORITY_COLORS: Record = { + low: 'text-green-600 bg-green-50', + medium: 'text-yellow-600 bg-yellow-50', + high: 'text-red-600 bg-red-50' +} + +export const FILTER_LABELS: Record = { + all: '전체', + active: '진행중', + completed: '완료' +} diff --git a/services/nextjs/src/entities/todo/model/index.ts b/services/nextjs/src/entities/todo/model/index.ts new file mode 100644 index 0000000..e359033 --- /dev/null +++ b/services/nextjs/src/entities/todo/model/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './constants' +export * from './store' \ No newline at end of file diff --git a/services/nextjs/src/entities/todo/model/store.ts b/services/nextjs/src/entities/todo/model/store.ts new file mode 100644 index 0000000..2a25ee4 --- /dev/null +++ b/services/nextjs/src/entities/todo/model/store.ts @@ -0,0 +1,166 @@ +import { create } from 'zustand' +import { CreateTodoData, UpdateTodoData, TodoStore, TodoFilter, TodoSort, PRIORITY_ORDER } from './types' +import { fetchTodos, createTodo, updateTodo, deleteTodo } from '../api/client' + +export const useTodoStore = create()((set, get) => ({ + // State + todos: [], + filter: 'all', + sort: 'createdAt', + searchQuery: '', + isLoading: false, + error: null, + + // Actions + addTodo: async (data: CreateTodoData) => { + if (!data.title?.trim()) { + console.warn('Todo title is required') + return + } + + try { + const newTodo = await createTodo(data) + set((state) => ({ + todos: [...state.todos, newTodo] + })) + } catch (error) { + console.error('Failed to create todo:', error) + set({ error: 'Failed to create todo' }) + } + }, + + updateTodo: async (id: string, data: UpdateTodoData) => { + if (!id) { + console.warn('Todo ID is required for update') + return + } + + try { + const updatedTodo = await updateTodo(id, data) + set((state) => ({ + todos: state.todos.map((todo) => + todo.id === id ? updatedTodo : todo + ) + })) + } catch (error) { + console.error('Failed to update todo:', error) + set({ error: 'Failed to update todo' }) + } + }, + + deleteTodo: async (id: string) => { + if (!id) { + console.warn('Todo ID is required for deletion') + return + } + + try { + await deleteTodo(id) + set((state) => ({ + todos: state.todos.filter((todo) => todo.id !== id) + })) + } catch (error) { + console.error('Failed to delete todo:', error) + set({ error: 'Failed to delete todo' }) + } + }, + + toggleTodo: async (id: string) => { + if (!id) { + console.warn('Todo ID is required for toggle') + return + } + + const todo = get().todos.find((t) => t.id === id) + if (!todo) return + + try { + await get().updateTodo(id, { completed: !todo.completed }) + } catch (error) { + console.error('Failed to toggle todo:', error) + } + }, + + loadTodos: async () => { + set({ isLoading: true, error: null }) + try { + const todos = await fetchTodos() + set({ todos, isLoading: false }) + } catch (error) { + console.error('Failed to load todos:', error) + set({ error: 'Failed to load todos', isLoading: false }) + } + }, + + setFilter: (filter: TodoFilter) => { + set({ filter }) + }, + + setSort: (sort: TodoSort) => { + set({ sort }) + }, + + setSearchQuery: (query: string) => { + set({ searchQuery: query }) + }, + + clearCompleted: async () => { + const completedTodos = get().todos.filter((todo) => todo.completed) + + try { + await Promise.all(completedTodos.map((todo) => deleteTodo(todo.id))) + set((state) => ({ + todos: state.todos.filter((todo) => !todo.completed) + })) + } catch (error) { + console.error('Failed to clear completed todos:', error) + set({ error: 'Failed to clear completed todos' }) + } + }, +})) + +// Selectors +export const useFilteredTodos = () => { + const { todos, filter, sort, searchQuery } = useTodoStore() + + if (!Array.isArray(todos)) { + return [] + } + + const filteredTodos = todos.filter((todo) => { + if (!todo || typeof todo !== 'object') { + return false + } + + const matchesFilter = + filter === 'all' || + (filter === 'active' && !todo.completed) || + (filter === 'completed' && todo.completed) + + const matchesSearch = + !searchQuery || + (todo.title && todo.title.toLowerCase().includes(searchQuery.toLowerCase())) || + (todo.description && todo.description.toLowerCase().includes(searchQuery.toLowerCase())) + + return matchesFilter && matchesSearch + }) + + // Sort todos + filteredTodos.sort((a, b) => { + if (!a || !b) return 0 + + switch (sort) { + case 'title': + return (a.title || '').localeCompare(b.title || '') + case 'priority': + return PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority] + case 'updatedAt': + return (b.updatedAt?.getTime() || 0) - (a.updatedAt?.getTime() || 0) + case 'createdAt': + default: + return (b.createdAt?.getTime() || 0) - (a.createdAt?.getTime() || 0) + } + }) + + return filteredTodos +} diff --git a/services/nextjs/src/entities/todo/model/types.ts b/services/nextjs/src/entities/todo/model/types.ts new file mode 100644 index 0000000..9f897d8 --- /dev/null +++ b/services/nextjs/src/entities/todo/model/types.ts @@ -0,0 +1,53 @@ +export interface Todo { + id: string + title: string + description?: string + completed: boolean + priority: 'low' | 'medium' | 'high' + createdAt: Date + updatedAt: Date +} + +export interface CreateTodoData { + title: string + description?: string + priority?: 'low' | 'medium' | 'high' +} + +export interface UpdateTodoData { + title?: string + description?: string + completed?: boolean + priority?: 'low' | 'medium' | 'high' +} + +export type TodoFilter = 'all' | 'active' | 'completed' + +export type TodoSort = 'createdAt' | 'updatedAt' | 'priority' | 'title' + +// Store types +export interface TodoState { + todos: Todo[] + filter: TodoFilter + sort: TodoSort + searchQuery: string + isLoading: boolean + error: string | null +} + +export interface TodoActions { + addTodo: (data: CreateTodoData) => Promise + updateTodo: (id: string, data: UpdateTodoData) => Promise + deleteTodo: (id: string) => Promise + toggleTodo: (id: string) => Promise + loadTodos: () => Promise + setFilter: (filter: TodoFilter) => void + setSort: (sort: TodoSort) => void + setSearchQuery: (query: string) => void + clearCompleted: () => Promise +} + +export type TodoStore = TodoState & TodoActions + +// Priority order for sorting +export const PRIORITY_ORDER = { high: 3, medium: 2, low: 1 } as const diff --git a/services/nextjs/src/entities/todo/ui/TodoItem.tsx b/services/nextjs/src/entities/todo/ui/TodoItem.tsx new file mode 100644 index 0000000..cc28103 --- /dev/null +++ b/services/nextjs/src/entities/todo/ui/TodoItem.tsx @@ -0,0 +1,53 @@ +'use client' + +import { Todo } from '../model/types' +import { useTodoStore } from '../model/store' +import { Button, Checkbox } from '../../../shared/ui' +import { PRIORITY_LABELS, PRIORITY_COLORS } from '../model' + +interface TodoItemProps { + todo: Todo +} + +export default function TodoItem({ todo }: TodoItemProps) { + const { deleteTodo, toggleTodo } = useTodoStore() + + const handleDelete = () => deleteTodo(todo.id) + const handleToggle = () => toggleTodo(todo.id) + + return ( +
+
+ +
+ + {todo.title} + + + {PRIORITY_LABELS[todo.priority]} + +
+
+ +
+ ) +} diff --git a/services/nextjs/src/entities/todo/ui/TodoList.tsx b/services/nextjs/src/entities/todo/ui/TodoList.tsx new file mode 100644 index 0000000..dbbba8f --- /dev/null +++ b/services/nextjs/src/entities/todo/ui/TodoList.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useFilteredTodos } from '../model/store' +import TodoItem from './TodoItem' + +export default function TodoList() { + const todos = useFilteredTodos() + + if (todos.length === 0) { + return ( +
+

할 일이 없습니다. 위에서 새로 추가해보세요.

+
+ ) + } + + return ( +
+ {todos.map((todo) => ( + + ))} +
+ ) +} diff --git a/services/nextjs/src/entities/todo/ui/index.ts b/services/nextjs/src/entities/todo/ui/index.ts new file mode 100644 index 0000000..d408b89 --- /dev/null +++ b/services/nextjs/src/entities/todo/ui/index.ts @@ -0,0 +1,2 @@ +export { default as TodoItem } from './TodoItem' +export { default as TodoList } from './TodoList' diff --git a/services/nextjs/src/features/todo-create/index.ts b/services/nextjs/src/features/todo-create/index.ts new file mode 100644 index 0000000..c5b2fed --- /dev/null +++ b/services/nextjs/src/features/todo-create/index.ts @@ -0,0 +1 @@ +export { default as TodoCreateForm } from './ui/TodoCreateForm' diff --git a/services/nextjs/src/features/todo-create/ui/TodoCreateForm.tsx b/services/nextjs/src/features/todo-create/ui/TodoCreateForm.tsx new file mode 100644 index 0000000..6a11150 --- /dev/null +++ b/services/nextjs/src/features/todo-create/ui/TodoCreateForm.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useState } from 'react' +import { useTodoStore } from '../../../entities/todo/model' +import { Input, Button } from '../../../shared/ui' +import { PRIORITY_LABELS, PRIORITY_COLORS } from '../../../entities/todo/model' + +export default function TodoCreateForm() { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium') + const addTodo = useTodoStore((state) => state.addTodo) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (title.trim()) { + addTodo({ + title: title.trim(), + description: description.trim() || undefined, + priority + }) + setTitle('') + setDescription('') + setPriority('medium') + } + } + + return ( +
+
+ setTitle(e.target.value)} + className="flex-1 h-10 border-gray-300 focus:border-green-500 focus:ring-green-500/20" + /> + +
+ + {/* 우선순위 선택 */} +
+ 우선순위: +
+ {(['low', 'medium', 'high'] as const).map((priorityOption) => ( + + ))} +
+
+
+ ) +} diff --git a/services/nextjs/src/features/todo-filter/index.ts b/services/nextjs/src/features/todo-filter/index.ts new file mode 100644 index 0000000..befa8c4 --- /dev/null +++ b/services/nextjs/src/features/todo-filter/index.ts @@ -0,0 +1 @@ +export { default as TodoFilter } from './ui/TodoFilter' diff --git a/services/nextjs/src/features/todo-filter/ui/TodoFilter.tsx b/services/nextjs/src/features/todo-filter/ui/TodoFilter.tsx new file mode 100644 index 0000000..ef20458 --- /dev/null +++ b/services/nextjs/src/features/todo-filter/ui/TodoFilter.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useTodoStore } from '../../../entities/todo/model/store' +import type { TodoFilter } from '../../../entities/todo/model/types' +import { Button } from '../../../shared/ui' + +const filterOptions: { value: TodoFilter; label: string; icon: string }[] = [ + { value: 'all', label: 'All', icon: '📋' }, + { value: 'active', label: 'Active', icon: '⏳' }, + { value: 'completed', label: 'Completed', icon: '✅' }, +] + +export default function TodoFilter() { + const { filter, setFilter } = useTodoStore() + + return ( +
+ {filterOptions.map((option) => ( + + ))} +
+ ) +} diff --git a/services/nextjs/src/features/todo-search/index.ts b/services/nextjs/src/features/todo-search/index.ts new file mode 100644 index 0000000..44ace00 --- /dev/null +++ b/services/nextjs/src/features/todo-search/index.ts @@ -0,0 +1 @@ +export { default as SearchInput } from './ui/SearchInput' diff --git a/services/nextjs/src/features/todo-search/ui/SearchInput.tsx b/services/nextjs/src/features/todo-search/ui/SearchInput.tsx new file mode 100644 index 0000000..6d70a23 --- /dev/null +++ b/services/nextjs/src/features/todo-search/ui/SearchInput.tsx @@ -0,0 +1,25 @@ +'use client' + +import { Search } from 'lucide-react' +import { Input } from '../../../shared/ui' +import { useTodoStore } from '../../../entities/todo/model/store' + +export default function SearchInput() { + const { searchQuery, setSearchQuery } = useTodoStore() + + const handleSearch = (query: string) => { + setSearchQuery(query) + } + + return ( +
+ + handleSearch(e.target.value)} + className="pl-10 h-10 border-gray-300 focus:border-blue-500 focus:ring-blue-500/20" + /> +
+ ) +} diff --git a/services/nextjs/src/shared/api/config.ts b/services/nextjs/src/shared/api/config.ts new file mode 100644 index 0000000..2f6f330 --- /dev/null +++ b/services/nextjs/src/shared/api/config.ts @@ -0,0 +1,10 @@ +// API 설정 - Next.js API Routes 사용 +export const API_CONFIG = { + baseURL: process.env.NEXT_PUBLIC_API_URL || '', + timeout: 10000, +} + +export const API_ENDPOINTS = { + todos: '/api/todos', + health: '/api/health', +} as const diff --git a/services/nextjs/src/shared/lib/cors.ts b/services/nextjs/src/shared/lib/cors.ts new file mode 100644 index 0000000..9099dcd --- /dev/null +++ b/services/nextjs/src/shared/lib/cors.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' + +/** + * CORS 헤더를 추가하는 유틸리티 함수 + * jotion (localhost:3001)에서 API 호출을 허용합니다 + */ +export function corsHeaders() { + return { + 'Access-Control-Allow-Origin': process.env.NEXT_PUBLIC_ALLOWED_ORIGIN || 'http://localhost:3001', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Credentials': 'true', + } +} + +/** + * CORS preflight 요청 처리 + */ +export function handleCorsPreFlight() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders(), + }) +} + +/** + * NextResponse에 CORS 헤더 추가 + */ +export function withCors(response: NextResponse) { + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response +} + diff --git a/services/nextjs/src/shared/lib/prisma.ts b/services/nextjs/src/shared/lib/prisma.ts new file mode 100644 index 0000000..4dcab10 --- /dev/null +++ b/services/nextjs/src/shared/lib/prisma.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }) + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma + diff --git a/services/nextjs/src/shared/lib/utils.ts b/services/nextjs/src/shared/lib/utils.ts new file mode 100644 index 0000000..ef515ba --- /dev/null +++ b/services/nextjs/src/shared/lib/utils.ts @@ -0,0 +1,10 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function generateId(): string { + return Math.random().toString(36).substring(2, 11) +} diff --git a/services/nextjs/src/shared/ui/button.tsx b/services/nextjs/src/shared/ui/button.tsx new file mode 100644 index 0000000..4d3a362 --- /dev/null +++ b/services/nextjs/src/shared/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/shared/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/services/nextjs/src/shared/ui/card.tsx b/services/nextjs/src/shared/ui/card.tsx new file mode 100644 index 0000000..f67b1bd --- /dev/null +++ b/services/nextjs/src/shared/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/shared/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/services/nextjs/src/shared/ui/checkbox.tsx b/services/nextjs/src/shared/ui/checkbox.tsx new file mode 100644 index 0000000..f0cab16 --- /dev/null +++ b/services/nextjs/src/shared/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/shared/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/services/nextjs/src/shared/ui/index.ts b/services/nextjs/src/shared/ui/index.ts new file mode 100644 index 0000000..1163a25 --- /dev/null +++ b/services/nextjs/src/shared/ui/index.ts @@ -0,0 +1,4 @@ +export { Button, buttonVariants } from "./button" +export { Input } from "./input" +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from "./card" +export { Checkbox } from "./checkbox" diff --git a/services/nextjs/src/shared/ui/input.tsx b/services/nextjs/src/shared/ui/input.tsx new file mode 100644 index 0000000..00b1a0b --- /dev/null +++ b/services/nextjs/src/shared/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/shared/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/services/nextjs/src/widgets/todo-app/index.ts b/services/nextjs/src/widgets/todo-app/index.ts new file mode 100644 index 0000000..273559e --- /dev/null +++ b/services/nextjs/src/widgets/todo-app/index.ts @@ -0,0 +1 @@ +export { TodoApp } from './ui/TodoApp' diff --git a/services/nextjs/src/widgets/todo-app/ui/TodoApp.tsx b/services/nextjs/src/widgets/todo-app/ui/TodoApp.tsx new file mode 100644 index 0000000..e10abc4 --- /dev/null +++ b/services/nextjs/src/widgets/todo-app/ui/TodoApp.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useEffect } from 'react' +import { Card, CardContent } from '../../../shared/ui' +import { TodoCreateForm } from '../../../features/todo-create' +import { TodoList } from '../../../entities/todo' +import { TodoFilter } from '../../../features/todo-filter' +import { SearchInput } from '../../../features/todo-search' +import { useTodoStore } from '../../../entities/todo/model/store' + +export function TodoApp() { + const loadTodos = useTodoStore((state) => state.loadTodos) + + useEffect(() => { + loadTodos() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ + + {/* Header */} +
+

My Todo App

+
+ + {/* Add Task Form */} +
+ +
+ + {/* Search and Filter */} +
+ + +
+ + {/* Task List */} +
+ +
+ +
+
+
+ ) +} diff --git a/tailwind.config.ts b/services/nextjs/tailwind.config.ts similarity index 100% rename from tailwind.config.ts rename to services/nextjs/tailwind.config.ts diff --git a/tsconfig.json b/services/nextjs/tsconfig.json similarity index 100% rename from tsconfig.json rename to services/nextjs/tsconfig.json