diff --git a/Monitoring-Jenkins/docker-compose.monitoring.yml b/Monitoring-Jenkins/docker-compose.monitoring.yml new file mode 100644 index 0000000..c4a90c1 --- /dev/null +++ b/Monitoring-Jenkins/docker-compose.monitoring.yml @@ -0,0 +1,28 @@ +services: + # Prometheus 설정 + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" # Prometheus 웹 인터페이스 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml # Prometheus 설정 파일 + networks: + - monitoring + + # Grafana 설정 + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" # Grafana 대시보드 + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin # 기본 관리자 비밀번호 + depends_on: + - prometheus + networks: + - monitoring + +networks: + monitoring: + driver: bridge \ No newline at end of file diff --git a/Monitoring-Jenkins/grafana/.gitignore b/Monitoring-Jenkins/grafana/.gitignore new file mode 100644 index 0000000..adbb97d --- /dev/null +++ b/Monitoring-Jenkins/grafana/.gitignore @@ -0,0 +1 @@ +data/ \ No newline at end of file diff --git a/Monitoring-Jenkins/jenkins/docker-compose.jenkins.yml b/Monitoring-Jenkins/jenkins/docker-compose.jenkins.yml new file mode 100644 index 0000000..df1f15f --- /dev/null +++ b/Monitoring-Jenkins/jenkins/docker-compose.jenkins.yml @@ -0,0 +1,16 @@ +version: "3.8" + +services: + jenkins: + image: jenkins/jenkins:lts + container_name: jenkins + restart: unless-stopped + user: root + ports: + - "8081:8080" + - "50000:50000" + volumes: + - /var/jenkins_home:/var/jenkins_home + - /var/run/docker.sock:/var/run/docker.sock + environment: + - TZ=Asia/Seoul diff --git a/Monitoring-Jenkins/prometheus/prometheus.yml b/Monitoring-Jenkins/prometheus/prometheus.yml new file mode 100644 index 0000000..7fc9b29 --- /dev/null +++ b/Monitoring-Jenkins/prometheus/prometheus.yml @@ -0,0 +1,23 @@ +global: + scrape_interval: 15s # 메트릭 수집 주기 + +scrape_configs: + # 백엔드 서비스 메트릭 수집 (백엔드 서버의 메트릭을 수집) + - job_name: 'backend' + scheme: https + metrics_path: '/metrics/back' + static_configs: + - targets: ['goodgamefinder.xyz'] + + # PostgreSQL Exporter 메트릭 수집 (Postgres Exporter가 설정되어 있다면) + - job_name: 'postgres' + scheme: https + metrics_path: '/metrics/db' + static_configs: + - targets: ['goodgamefinder.xyz'] + + - job_name: 'node_exporter' + metrics_path: '/metrics/system' + scheme: https + static_configs: + - targets: ['goodgamefinder.xyz'] \ No newline at end of file diff --git a/README.md b/README.md index 65f9572..366f8fe 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,140 @@ -# SSAFY 12기 자율 프로젝트 - D106팀 +# 🎮 GG (Good Game Finder) - 3가지 엔진을 탑재한 AI 게임 추천 플랫폼 -SSAFY 13기 자율 프로젝트 D106팀의 공식 Repository입니다. +[![React](https://img.shields.io/badge/React-18.2.0-61DAFB?style=for-the-badge&logo=react)](https://reactjs.org/) [![Spring Boot](https://img.shields.io/badge/Spring_Boot-3.5.6-6DB33F?style=for-the-badge&logo=spring)](https://spring.io/projects/spring-boot) [![FastAPI](https://img.shields.io/badge/FastAPI-0.120.0-009688?style=for-the-badge&logo=fastapi)](https://fastapi.tiangolo.com/) [![LangChain](https://img.shields.io/badge/LangChain-1.0.3-8A2BE2?style=for-the-badge)](https://www.langchain.com/) +**GG(Good Game Finder)**는 사용자의 취향과 상황에 맞춰 최적의 게임을 발견할 수 있도록, 각기 다른 강점을 가진 **3가지 핵심 추천 엔진**을 탑재한 AI 기반 개인화 게임 추천 플랫폼입니다. +MSA(Microservice Architecture) 기반으로 설계되었으며, 사용자의 복합적인 요구사항을 만족시키기 위해 전통적인 머신러닝, 생성형 AI, 로직 기반 필터링을 모두 활용합니다. -## 🗓️ 프로젝트 주요 일정 +--- -| 날짜 | 내용 | 비고 | -| --- | --- | --- | -| 2025.10.10(금) | 자율 프로젝트 시작 및 첫 팀 미팅 | | -| 2025.10.15(수) | 프로젝트 계획서 제출 및 1차 교보재 신청 (예정) | | -| 2025.10.17(금) | Field Trip | | -| 2025.10.24(금) | **중간 평가** | 기획/설계 내용 기반 발표 (10분 내외) | -| 2025.11.20(목) | **최종 평가** | 최종 발표 (시연 포함 15분) | -| 2025.11.24(월) ~ | 포트폴리오 위크 | 프로젝트 회고 및 마무리 | +## ✨ 주요 기능 및 설계 -## Ground Rules +### 🖥️ 프론트엔드 (UI/UX) -우리 팀은 SSAFY 자율 프로젝트 Ground Rule을 준수합니다. +사용자 경험의 중심이 되는 웹 애플리케이션으로, React 기반의 반응형 SPA(Single Page Application)로 구축되었습니다. -- **미팅 시간** - - **오전 미팅(조회):** 매일 09:00 시작 (라이브 방송 시 방송 후) - - **오후 미팅(종례):** 매일 17:30 시작, **금요일은 17:00 시작** -- **협업 Tool** - - **GitLab, Jira, Mattermost, Webex**를 반드시 사용합니다. - - Mattermost 공지는 이모지로 확인 반응을 남겨 적극적으로 소통합니다. -- **프로젝트 관리** - - 매일 **Stand up Scrum 회의**로 하루를 시작합니다. - - 매주 **금요일** Sprint 회고를 진행하고, 회고록을 팀 채널에 공유합니다. - - 기획 내용은 `Readme.md`에 꾸준히 업데이트합니다. +- **컴포넌트 기반 아키텍처**: 재사용성과 유지보수성을 높이기 위해 `features`와 `shared` 디렉토리 구조를 채택했습니다. 각 기능(게임, 프로필, 인증 등)별로 컴포넌트를 분리하고, 공용 컴포넌트(레이아웃, UI 요소 등)는 `shared`에서 관리하여 일관성을 유지합니다. +- **서버 상태 관리**: `TanStack Query`를 핵심 라이브러리로 사용하여 서버와의 비동기 통신을 관리합니다. 이를 통해 데이터 캐싱, 자동 리프레시, 로딩 및 에러 상태 처리를 선언적으로 구현하여 코드 복잡도를 줄이고 사용자 경험을 향상시켰습니다. +- **UI 디자인 시스템**: `Tailwind CSS`와 `shadcn/ui`를 기반으로 자체 디자인 시스템을 구축하여, 프로젝트 전반에 걸쳐 통일성 있고 세련된 UI를 제공합니다. `Recharts`를 활용한 데이터 시각화, `embla-carousel`을 이용한 캐러셀 등 동적인 UI 요소를 적극적으로 사용합니다. +- **전역 상태 및 라우팅**: `React Context`를 통해 사용자 인증 정보(`AuthContext`), 테마(`ThemeProvider`) 등 전역 상태를 관리하며, `React Router`를 사용하여 페이지 간 부드러운 전환을 제공합니다. -## 👥 팀원 소개 +### ⚙️ 3가지 핵심 추천 엔진 + +GG 플랫폼은 사용자의 다양한 탐색 시나리오에 대응하기 위해 세 가지의 독립적인 추천 엔진을 제공합니다. + +#### 1. 개인 맞춤 추천 (ML-based Recommendation) + +> "나의 플레이 패턴을 분석해서 좋아할 만한 게임을 자동으로 추천해줘." + +- **담당 서비스**: `gg-recommend` +- **기술 방식**: **전통적 머신러닝 (하이브리드 필터링)** +- **상세 로직**: + 1. **데이터 분석**: 사용자의 Steam 라이브러리, 플레이 시간, '좋아요' 기록, 리뷰 평가 등 다양한 상호작용에 가중치를 부여하여 단일 상호작용 점수(`r_ui`)를 계산합니다. + 2. **후보군 생성 (Candidate Generation)**: + - **협업 필터링 (CF)**: 계산된 상호작용 점수를 기반으로 **Item-Item k-NN** 모델을 사용하여 '나와 비슷한 취향의 유저들이 즐긴 게임'을 후보로 추천합니다. + - **콘텐츠 기반 (CB)**: **TF-IDF**와 **SVD**로 벡터화된 게임의 콘텐츠(장르, 태그 등) 유사도를 기반으로 '내가 즐긴 게임과 비슷한 게임'을 후보로 추천합니다. + 3. **순위 재조정 (Ranking)**: 생성된 후보군들을 인기도, 최신성, 리뷰 점수 등 다양한 기준으로 재정렬하여 최종 추천 목록을 제공합니다. + +#### 2. 대화형 AI 추천 (Conversational AI Recommendation) + +> "최근에 친구랑 할만한 긴장감 넘치는 멀티플레이 FPS 게임 뭐 없을까?" + +- **담당 서비스**: `AI-SERVER` +- **기술 방식**: **생성형 AI (RAG - Retrieval-Augmented Generation)** +- **상세 로직**: + 1. **의도 분석**: 사용자의 자연어 질문 의도(단순 추천, 비교, 조건 검색 등)를 `LangChain`을 통해 분류합니다. + 2. **정보 검색 (Retrieval)**: + - **하이브리드 검색**: 질문의 의도와 문맥을 모두 고려하기 위해 **키워드 기반 검색(BM25)**과 **의미 기반 벡터 검색**을 동시에 사용해 가장 관련성 높은 게임들을 DB(MongoDB Atlas)에서 찾아냅니다. + 3. **프롬프트 생성 및 LLM 호출 (Generation)**: 검색된 게임 정보, 사용자 정보(필요시), 원본 질문을 조합하여 LLM(대규모 언어 모델)을 위한 정교한 프롬프트를 생성합니다. LLM은 이 프롬프트를 바탕으로 추천 이유를 포함한 자연스러운 답변을 만들어냅니다. + +#### 3. 아키네이터 추천 (Filter-based Recommendation) + +> "내가 원하는 장르, 분위기, 플레이 방식을 하나씩 선택해서 딱 맞는 게임을 찾아보고 싶어." + +- **담당 서비스**: `gg-backend` +- **기술 방식**: **로직 기반 필터링 시스템** +- **상세 로직**: + 1. **질문-답변 진행**: '스무고개'처럼 시스템이 제시하는 질문(e.g., "혼자서 하는 게임인가요?", "스토리가 중요한가요?")에 사용자가 답변합니다. + 2. **후보군 필터링**: 각 답변에 해당하는 게임 속성(장르, 태그 등)을 기준으로 전체 게임 목록을 실시간으로 필터링하여 후보군을 점차 좁혀나갑니다. + 3. **결과 제시**: 모든 질문이 끝나면, 최종적으로 남은 게임을 추천 결과로 제시합니다. 머신러닝 모델 없이, 정해진 로직에 따라 빠르고 명확한 결과를 제공하는 것이 특징입니다. + +--- + +## 🏗️ 아키텍처 및 기술 스택 + +```mermaid +graph TD + subgraph "사용자" + A[Browser] + end + + subgraph "인프라 (Docker)" + B[Nginx] + C[gg-frontend] + D[gg-backend] + E[gg-recommend] + F[AI-SERVER] + G[gg-ingester] + H[PostgreSQL] + I[Redis] + J[MongoDB Atlas] + end -| 포지션 | 이름 | 희망 기술 | -| --- | --- | --- | -| 팀장, 프론트엔드 | 윤문정 | UI/UX 기획 및 프론트엔드 개발 | -| 백엔드 | 이창환 | 실시간 데이터 파이프라인 설계 및 백엔드 API 개발 | -| AI/MLOps | 송하윤 | AI 모델 학습 및 MLOps(CI/CD) 파이프라인 구축 | -| 백엔드 | 하승재 | 웹소켓 기반 서버 및 백엔드 API 개발 | -| 백엔드 | 박범준 | 백엔드 API 개발 및 DB 설계 | -| 프론트엔드 | 박재연 | 프론트엔드 개발 | + subgraph "외부 API" + K[Steam API] + L[LLM API] + end + A -- HTTPS --> B + B -- / --> C + B -- /api --> D + + D -- 개인 맞춤 추천 요청 --> E + D -- 아키네이터 로직 처리 --> D + D -- 대화형 AI 추천 요청 --> F + + E -- 학습/추론 데이터 --> H + D -- 메인 데이터 --> H + G -- 수집 데이터 저장 --> H + + F -- 벡터/메타데이터 --> J + F -- LLM 호출 --> L + G -- 플레이어 수 수집 --> K +``` + +| 서비스 | 역할 | 주요 기술 | +| :--- | :--- | :--- | +| **gg-frontend** | 웹 프론트엔드 | `React`, `TypeScript`, `Vite`, `Tailwind CSS`, `shadcn/ui`, `TanStack Query` | +| **gg-backend** | 메인 API 서버, **아키네이터 엔진** | `Java 21`, `Spring Boot`, `JPA`, `QueryDSL`, `JJWT` | +| **gg-recommend** | **개인 맞춤 추천 엔진 (ML)** | `Python`, `FastAPI`, `Scikit-learn`, `Pandas`, `SQLAlchemy` | +| **AI-SERVER** | **대화형 AI 추천 엔진 (RAG)** | `Python`, `FastAPI`, `LangChain`, `Pytorch`, `Sentence-Transformers`, `MongoDB` | +| **gg-ingester** | 데이터 수집기 | `Python`, `FastAPI`, `APScheduler`, `AsyncPG`, `AIOHTTP` | +| **Monitoring-Jenkins** | 모니터링 & CI/CD | `Prometheus`, `Grafana`, `Jenkins`, `Docker`, `Docker Compose` | +| **데이터베이스** | 데이터 저장소 | `PostgreSQL`, `Redis`, `MongoDB` | + +--- + +## 🚀 시작하기 (Getting Started) + +본 프로젝트는 `docker-compose`를 사용하여 모든 서비스를 한 번에 실행할 수 있습니다. + +1. **Repository 클론 및 환경 변수 설정** + ```bash + git clone https://github.com/your-repo/S13P31D106.git + cd S13P31D106 + # 각 서비스의 .env.example 파일을 참고하여 .env 파일 생성 및 설정 + ``` + +2. **Docker Compose 실행** + ```bash + # 필요한 docker-compose 파일을 조합하여 실행 + docker-compose -f ... up --build + ``` + +3. **애플리케이션 접속**: `http://localhost` + +--- ## ✍️ Git Commit Convention @@ -54,4 +148,15 @@ SSAFY 13기 자율 프로젝트 D106팀의 공식 Repository입니다. | `test` | 테스트 코드 추가 및 수정 | | `chore` | 빌드 업무, 패키지 매니저 등 수정 | -**예시:** `feat: 로그인 기능 추가` \ No newline at end of file +--- + +## 👥 팀원 소개 + +| 포지션 | 이름 | +| --- | --- | +| 팀장, 프론트엔드 | 윤문정 | +| 백엔드 | 이창환 | +| AI/MLOps | 송하윤 | +| 백엔드 | 하승재 | +| 백엔드/DevOps | 박원준 | +| 프론트엔드 | 박재연 | diff --git "a/exec/01_\353\271\214\353\223\234_\353\260\217_\353\260\260\355\217\254_\352\260\200\354\235\264\353\223\234.md" "b/exec/01_\353\271\214\353\223\234_\353\260\217_\353\260\260\355\217\254_\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 0000000..088812d --- /dev/null +++ "b/exec/01_\353\271\214\353\223\234_\353\260\217_\353\260\260\355\217\254_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,743 @@ +# Porting Manual (Integrated) + +## 1. 시스템 개요 + +### 1.1. 아키텍처 +- **Reverse Proxy 계층**: `nginx` 컨테이너가 80/443 포트를 오픈하고 정적 자산, API, FastAPI 추천 엔드포인트를 라우팅합니다. +- **애플리케이션 계층** + - `gg_backend`: Spring Boot 3.5.6, JDK 21, JPA + Redis + PostgreSQL. + - `gg_recommend`: FastAPI 0.120.0, 모델 아티팩트를 이용해 추천 결과 제공. + - `gg_frontend`: Vite + React 18로 빌드된 SPA(정적 파일은 Nginx가 서빙). + - `gg_ingester`: FastAPI 기반 데이터 수집(steam player data), Redis/DB 연동. + - `gg_trainer`: 주기적으로 추천 모델을 재학습하고 FastAPI 컨테이너에 리로드 신호를 전송. + - `ai_server`: Python 3.12, FastAPI, LangChain 기반의 AI 서버 (RAG, LLM 활용). +- **데이터 계층**: PostgreSQL 18, Redis 7-alpine, MongoDB (Atlas). +- **모니터링**: + - `docker-compose.monitor.yml`: Prometheus + node-exporter + cAdvisor. + - `AI-server` 모니터링: Prometheus (9090), Grafana (3000). + - `Monitoring-Jenkins`(Web서버 모니터링, CI/CD): Prometheus (9090), Grafana (3000), Jenkins (8081) + +### 1.2. 프로젝트 사용 도구 +| 구분 | 도구 | 비고 | +| --- | --- | --- | +| 이슈 관리 | Jira | SSAFY 표준 프로세스 | +| 형상 관리 | GitLab | mono-repo (`gg`) | +| 커뮤니케이션 | Mattermost, Webex | README Ground Rule 준수 | +| 문서/디자인 | Notion, Figma (필요 시) | 포팅 매뉴얼/디자인 산출물 | +| 모니터링 | Prometheus, Grafana | 메트릭 수집 및 대시보드 시각화 | +| CI/CD | Jenkins | 자동 빌드·배포 파이프라인 구축 | +| AI/ML | LangChain, HuggingFace | AI Server 활용 | + +## 2. 시스템 환경 정보 + +### 2.1. 호스트/클라우드 환경 +- **권장 OS**: Ubuntu 22.04 LTS (x86_64, Kernel 5.15+). +- **권장 스펙**: vCPU 4core 이상, RAM 16GB 이상 (AI 모델 구동 고려), SSD 100GB. +- **네트워크**: 외부 80/443, 내부 Docker 네트워크 `edge-net`. +- **보안**: UFW 또는 보안 그룹에서 22/80/443/5432/6379/8443 등 필요한 포트만 허용. + +### 2.2. 런타임/컨테이너 +- Docker Engine 24.0+ / Docker Compose V2. +- 컨테이너 네트워크: `edge-net` (external=true). +- 모든 서비스는 `.env` 파일과 `docker-compose.*.yml`로 관리. + +### 2.3. 애플리케이션 컨테이너 +| 서비스 | 이미지/빌드 | 포트/엔드포인트 | 비고 | +| --- | --- | --- | --- | +| `gg_backend` | `gg_backend/Dockerfile` (Gradle multi-stage) | 내부 8080 | `SPRING_PROFILES_ACTIVE=prod`, `/actuator/health` | +| `gg_recommend` | `gg_recommend/Dockerfile` | 내부 8000 | `/health`, 아티팩트 볼륨 공유 | +| `gg_frontend` | `gg_frontend/Dockerfile` | 개발 4173, 배포 시 Nginx 정적 서빙 | 빌드 결과물 `dist/` | +| `gg_ingester` | `gg_ingester/Dockerfile` | 내부 8001 | Steam 데이터 수집 | +| `gg_trainer` | `gg_recommend/Dockerfile.trainer` | Cron 기반 | 매일 03:30 모델 재학습 | +| `nginx` | `nginx:1.27-alpine` | 80/443 | 정적 파일 + 프록시 | +| `ai_server` | `AI-server/Dockerfile` | 내부 8443 | `/docs` Swagger UI, LangChain | + +### 2.4. 데이터베이스 & 캐시 +| 항목 | 버전 | 포트 | 비고 | +| --- | --- | --- | --- | +| PostgreSQL | 18 | 5432 | `docker-compose.db.yml`, volume=`pgdata` | +| Redis | 7-alpine | 6379 | AOF on, volume=`redisdata`, password 사용 | +| MongoDB | Atlas (Cloud) | - | Vector Search & Game Data | + +### 2.5. 모니터링 & 메트릭 +- Prometheus + node-exporter + cAdvisor (`docker-compose.monitor.yml`). +- AI Server 모니터링: Prometheus (9090), Grafana (3000). +- Web Server 모니터링: 백엔드, DB, CPU 메트릭 수집, Prometheus (9090), Grafana (3000). +- Scrape 주기: 30초. 주요 메트릭: HTTP 요청 수/지연시간, JVM 메모리·GC, 컨테이너 CPU/메모리, 시스템 리소스. +- Spring Boot Actuator → Prometheus exporter (`management.endpoints.web.exposure.include=prometheus,health,info`). + +### 2.6. 빌드 도구 +- Backend: Gradle 8.10.2, Java 21. +- Frontend: Node.js 20-alpine, npm + Vite. +- Python 서비스: requirements.txt (FastAPI stack), Python 3.11-slim. +- AI Server: Python 3.12, uv/pip. + +## 3. 배포 사전 준비 + +### 3.1. 원격 접속 +```bash +ssh -i {PEM_KEY} ubuntu@{SERVER_IP_OR_DOMAIN} +``` +- SSH 키 기반 인증 권장 (`ssh-keygen`, `ssh-copy-id`). +- 첫 접속 후 `sudo passwd ubuntu` 등 필요 시 계정 관리. + +### 3.2. 서버 기본 설정 +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y ca-certificates curl git unzip net-tools vim + +# Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh + +# Compose (v2) +sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +# docker 그룹 +sudo usermod -aG docker $USER +newgrp docker + +# 방화벽 (UFW 사용 시) +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +### 3.2.1. Nginx + SSL (사전 셋업) +1. `/etc/nginx/conf.d/app.conf` 작성. + ```app.conf + # 80 → 443 리다이렉트 + server { + listen 80; + server_name goodgamefinder.xyz; + + # 인증 경로는 절대 리다이렉트 금지 (우선 매칭용 ^~ 사용) + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + try_files $uri =404; + } + + # 그 외 모든 요청은 HTTPS로 + location / { + return 301 https://$host$request_uri; + } + } + + # HTTPS 종단 + server { + listen 443 ssl; + http2 on; + server_name goodgamefinder.xyz; + + # 인증서 경로 + ssl_certificate /etc/letsencrypt/live/goodgamefinder.xyz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/goodgamefinder.xyz/privkey.pem; + + # HSTS(선택: 처음엔 짧게 테스트) + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # 공통 프록시 헤더 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 파일 업로드가 있다면 필요시 조정 + client_max_body_size 20m; + + location /py/ { + proxy_pass http://gg_recommend:8000; + proxy_read_timeout 60s; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Prefix /py; + } + + location = /py/docs { + allow 14.46.142.210; + deny all; + + proxy_pass http://gg_recommend:8000/py/docs; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /py/openapi.json { + allow 14.46.142.210; + deny all; + + proxy_pass http://gg_recommend:8000/py/openapi.json; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /ingester/ { + proxy_pass http://gg_ingester:8001; + proxy_read_timeout 60s; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /ingester/docs { + allow 14.46.142.210; + deny all; + + proxy_pass http://gg_ingester:8001/ingester/docs; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /ingester/openapi.json { + allow 14.46.142.210; + deny all; + + proxy_pass http://gg_ingester:8001/ingester/openapi.json; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ---- React 정적 파일 (루트) ---- + root /usr/share/nginx/html; + index index.html; + + # 프론트 라우터 대응 (SPA) + location / { + try_files $uri /index.html; + } + + + location = /api/v1/chat { + proxy_pass http://3.38.180.144:8443; # 슬래시 없음(정확 매핑) + proxy_read_timeout 60s; + + proxy_ssl_verify off; + proxy_set_header Host $proxy_host; # 외부 서버 Host 기대 시 유리 + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ---- Spring Boot API ---- + location /api/ { + proxy_pass http://gg_backend:8080; # 컨테이너 이름으로 접근 + proxy_read_timeout 60s; + # 쿠키를 HTTPS로 강제하고 싶다면 (애플리케이션에서 설정하는 것이 더 선호) + # proxy_cookie_path / "/; SameSite=None; Secure"; + } + + location /v3/api-docs { + allow 14.46.142.210; + deny all; + + proxy_pass http://gg_backend:8080/v3/api-docs; + proxy_read_timeout 60s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 백엔드 스웨거로 직접 프록시 + location ^~ /swagger-ui/ { + allow 14.46.142.210; + deny all; + + proxy_pass http://gg_backend:8080/swagger-ui/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /metrics/back { + proxy_set_header Host $host; + proxy_pass http://gg_backend:8080/actuator/prometheus; + } + + location /metrics/db { + # allow 1.2.3.4; + # deny all; + + proxy_set_header Host $host; + proxy_pass http://postgres_exporter:9187/metrics; + } + + location /metrics/system { + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + + # ① host.docker.internal 경로 (권장: 이미 extra_hosts=host-gateway 설정했음) + proxy_pass http://172.19.0.1:9100/metrics; + + # ② 위가 불안정하면, 아래 IP로 교체 (컨테이너 내부에서 default gateway IP를 써야 정확) + # proxy_pass http://:9100/metrics; + } + + # 보안 헤더(필요시 조정) + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + # add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' https: data:;" always; + } + ``` +2. `docker-compose.nginx.yml`의 `certbot` 서비스 실행(볼륨: `/etc/letsencrypt`, `/var/www/certbot`, 초기 명령: `sleep infinity`) 후 인증서 발급: + ```bash + sudo docker-compose -f docker-compose.nginx.yml up -d certbot + docker exec -it certbot certbot certonly --webroot \ + --webroot-path=/var/www/certbot \ + --email {ADMIN_EMAIL} --agree-tos --no-eff-email \ + -d {DOMAIN} -d www.{DOMAIN} + ``` +3. `sudo docker-compose -f docker-compose.nginx.yml up -d nginx`. +4. 변경 시 `docker exec edge-nginx nginx -s reload`. +5. 자동 갱신 (크론): + ```bash + sudo crontab -e + 0 3 * * * docker exec certbot certbot renew --quiet && docker exec edge-nginx nginx -s reload + ``` + +### 3.3. 프로젝트 가져오기 +```bash +cd /home/ubuntu +sudo mkdir -p projects && sudo chown $USER:$USER projects +cd /home/ubuntu/projects +git clone {GITLAB_REPOSITORY_URL} . +``` + +### 3.4. 환경 변수 파일 생성 +각 서비스별 `.env` 를 작성합니다. + +#### 3.4.1 Backend (`gg_backend/.env`) +```env +DATABASE_URL=jdbc:postgresql://goodgamefinder.xyz:5432/GG +DATABASE_USERNAME=root +DATABASE_PASSWORD=TXxbfhyj9iRmyBqnT3_V + +SERVER_PORT=8080 +SERVER_ADDRESS=0.0.0.0 + +JWT_SECRET=Qp$N,SPFgnrb+&`Qg&]Sz-TG[c%[>;W{I?yP#W,fXw%6,7j}:llb +JWT_ACCESS_TOKEN_EXPIRATION_MS=3600000 +JWT_REFRESH_TOKEN_EXPIRATION_MS=3600000 + +STEAM_API_KEY=31498A1AC6A3100567330547DC8D686C + +DOMAIN_URL=http://goodgamefinder.xyz +REDIRECT_URL=http://goodgamefinder.xyz +CALLBACK_URL=http://goodgamefinder.xyz/api/auth/steam/callback + +# Redis +REDIS_HOST=goodgamefinder.xyz +REDIS_PORT=6379 +REDIS_PASSWORD=NEItZh3eFZ6jimFFn2TNrAHba4TOB +REDIS_DB=0 +``` + +#### 3.4.2 Frontend (`gg_frontend/.env`) +```env +VITE_API_BASE_URL=https://goodgamefinder.xyz +VITE_RECOMMEND_API_BASE_URL=https://goodgamefinder.xyz/py/ +VITE_CHAT_API_BASE_URL=http://3.38.180.144:8443 +``` + +#### 3.4.3 Recommend (`gg_recommend/.env`) +```env +# Java: jdbc:postgresql://goodgamefinder.xyz:5432/GG (user/pw은 각자) +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=GG +DB_USER=root +DB_PASSWORD=TXxbfhyj9iRmyBqnT3_V + +# 가중치(초기값) — A/B로 조정 +ALPHA=0.6 +BETA=0.1 +GAMMA=0.15 +DELTA=0.1 +KAPPA=0.2 + +# 후보 크기 +CF_K=200 +CB_TOPM=500 +FINAL_TOPN=40 + +# 아티팩트 경로 +ART_DIR=/app/data/artifacts +``` + +#### 3.4.4 Ingester (`gg_ingester/.env`) +```env +# Database +DB_HOST=goodgamefinder.xyz +DB_PORT=5432 +DB_NAME=GG +DB_USER=root +DB_PASSWORD=TXxbfhyj9iRmyBqnT3_V + +# Redis +REDIS_HOST=goodgamefinder.xyz +REDIS_PORT=6379 +REDIS_PASSWORD=NEItZh3eFZ6jimFFn2TNrAHba4TOB +REDIS_DB=0 + +# Steam API +STEAM_API_KEY=31498A1AC6A3100567330547DC8D686C + +# 선택적: 수집 파라미터 커스터마이징 +COLLECTOR_START_CONC=150 +COLLECTOR_MAX_CONC=400 +COLLECTOR_WINDOW=400 +COLLECTOR_TARGET_RLR=0.015 +``` + +#### 3.4.5 루트 `.env` +```env +POSTGRES_USER=root +POSTGRES_PASSWORD=TXxbfhyj9iRmyBqnT3_V +POSTGRES_DB=GG +TZ=Asia/Seoul + +REDIS_HOST=goodgamefinder.xyz +REDIS_PORT=6379 +REDIS_PASSWORD=NEItZh3eFZ6jimFFn2TNrAHba4TOB +REDIS_DB=0 + +SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/GG +``` + +#### 3.4.6 AI Server (`AI-server/.env`) +```ini +# MongoDB 설정 +MONGODB_URI=mongodb://:@: +MONGODB_DB_NAME=game_recommendation +MONGODB_COLLECTION_NAME=games + +# MongoDB Atlas Search 인덱스 +TEXT_SEARCH_INDEX=default +VECTOR_SEARCH_INDEX=vector_index + +# PostgreSQL 설정 +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=password +POSTGRES_DB=gamedb + +# Local LLM & Embedding 설정 +LLM_ENDPOINT= +LLM_MODEL_NAME= +LLM_TEMPERATURE=0.7 +EMBEDDING_MODEL=intfloat/multilingual-e5-small +EMBEDDING_DIMENSION=384 + +# RAG 설정 +RETRIEVER_K=5 +RRF_K=60 + +# LangChain / LangSmith 설정 (선택사항) +LANGCHAIN_TRACING_V2=true +LANGCHAIN_ENDPOINT=https://api.smith.langchain.com +LANGCHAIN_API_KEY= +LANGCHAIN_PROJECT= + +# Monitoring +ENABLE_METRICS=true +``` + +### 3.5. 추천 모델 아티팩트 +- `gg_recommend/app/data/artifacts` 내에 `cb_vectors.npz` 등 학습 결과 파일 배치. +- Trainer/Recommend 컨테이너가 동일 경로를 volume 으로 공유. + +### 3.6. DB 덤프 파일 +- 복원 방법은 `04_DB_덤프_복원_가이드.md` 참고 + +## 4. 배포 절차 + +### 4.1. Docker 네트워크 생성 +```bash +docker network create edge-net +``` + +### 4.2. 데이터베이스 & 캐시 기동 +```bash +sudo docker-compose -f docker-compose.db.yml up -d +sudo docker-compose -f docker-compose.db.yml ps +``` + +### 4.3. 데이터베이스 초기화 (최초 배포 시) +```bash +docker exec -i postgres psql -U {POSTGRES_USER} -d {DB_NAME} < exec/db_dump.sql +``` + +### 4.4. 웹/추천 스택 빌드 & 실행 +```bash +sudo docker-compose -f docker-compose.web.yml up -d --build +sudo docker-compose -f docker-compose.web.yml logs -f +``` + +### 4.5. 데이터 수집 & 학습 +```bash +sudo docker-compose -f docker-compose.ingester.yml up -d --build +sudo docker-compose -f docker-compose.web.yml up -d gg_trainer +``` + +### 4.6. AI Server 빌드 & 실행 +```bash +cd AI-server +docker-compose up -d --build +docker-compose logs -f ai-server +``` + +### 4.7. 헬스 체크 & 검증 +```bash +curl http://localhost:8080/actuator/health +curl http://localhost:8000/health +curl https://{DOMAIN}/health +# AI Server +curl http://localhost:8443/docs +``` +브라우저에서 `https://{DOMAIN}`, `https://{DOMAIN}/api/actuator/health`, `https://{DOMAIN}/py/health` 확인. + +### 4.8. 포트/리소스 확인 +```bash +sudo ss -tlnp | grep -E ':(80|443|8080|8000|5432|6379|8443)' +docker ps -a +docker stats +``` + +## 5. 서버 운영 & 관리 + +### 5.1. 서비스 시작/중지 +```bash +# 전체 시작 +sudo docker-compose -f docker-compose.db.yml up -d +sudo docker-compose -f docker-compose.web.yml up -d +sudo docker-compose -f docker-compose.ingester.yml up -d +sudo docker-compose -f docker-compose.nginx.yml up -d +# AI Server 시작 +cd AI-server && docker-compose up -d + +# Monitoring / Jenkins +cd ../Monitoring-Jenkins +sudo docker-compose -f docker-compose.monitoring.yml up -d +sudo docker-compose -f jenkins/docker-compose.jenkins.yml up -d + +# 전체 중지 (역순) +sudo docker-compose -f docker-compose.monitoring.yml down +sudo docker-compose -f jenkins/docker-compose.jenkins.yml down +cd ../AI-server && docker-compose down +cd .. +sudo docker-compose -f docker-compose.nginx.yml down +sudo docker-compose -f docker-compose.ingester.yml down +sudo docker-compose -f docker-compose.web.yml down +sudo docker-compose -f docker-compose.db.yml down +``` + +### 5.2. 로그 확인 +```bash +sudo docker-compose -f docker-compose.web.yml logs -f +docker logs -f gg_backend +docker logs -f gg_recommend +docker logs -f edge-nginx +docker logs --tail 100 gg_backend +# AI Server Log +cd AI-server && docker-compose logs -f ai-server +``` + +### 5.3. 모니터링 +- node-exporter: 9100, cAdvisor: 8082. +- Spring Actuator → Prometheus scrape. +- **Web Server Monitoring** (전용 모니터링 서버) + - Prometheus: `http://{HOST}:9090` + - Grafana: `http://{HOST}:3000` +- **AI Server Monitoring**: (AI 서버 내부에 별도 설치) + - AI Server는 Web 모니터링과 독립적으로 +AI 서버 내부에서 자체 Prometheus + Grafana를 별도로 운영합니다. + - Prometheus: `http://{HOST}:9090` (AI Server 전용 포트 확인 필요, 기존 9090과 충돌 주의. AI Server의 docker-compose.yml 확인 요망) + - Grafana: `http://{HOST}:3000` + +### 5.4. 백업 & 복구 +```bash +# PostgreSQL 백업 +docker exec postgres pg_dump -U {POSTGRES_USER} {DB_NAME} > backup_$(date +%Y%m%d_%H%M%S).sql + +# PostgreSQL 복구 +docker exec -i postgres psql -U {POSTGRES_USER} {DB_NAME} < backup_20241117_120000.sql + +# Redis AOF 파일 복사 +docker cp redis:/data/ appendonly.aof ./redis_aof_backup.aof +``` + +### 5.5. 업데이트/재배포 + +#### 5.5.1 수동 업데이트/재배포 +```bash +git pull origin main +sudo docker-compose -f docker-compose.web.yml up -d --build +docker exec edge-nginx nginx -s reload + +# AI Server Update +cd AI-server +docker-compose up -d --build + +# Monitoring-Jenkins 서버 접속 후 +cd ../Monitoring-Jenkins +sudo docker-compose -f docker-compose.monitoring.yml up -d --build +sudo docker-compose -f jenkins/docker-compose.jenkins.yml up -d --build +``` + +#### 5.5.2 Web 서비스 Jenkins 자동 배포(CI/CD) +아래 Jenkins Pipeline은 develop 브랜치 변경을 트리거로, +Web 서버에 SSH로 접속하여 코드 Pull → Spring/FastAPI 컨테이너 재빌드·재기동 → React 빌드 → Nginx 재시작까지 한 번에 수행한다. +```groovy +pipeline { + agent any + + options { + disableConcurrentBuilds() + timestamps() + } + + environment { + BACKEND_HOST = "goodgamefinder.xyz" + BACKEND_USER = "ubuntu" + + APP_PATH = "/home/ubuntu/projects/S13P31D106" + COMPOSE_APP = "docker-compose.web.yml" + COMPOSE_NGINX = "docker-compose.nginx.yml" + + GIT_URL = "https://lab.ssafy.com/s13-final/S13P31D106.git" + GIT_BRANCH = "develop" + } + + stages { + // 변경 감지/웹훅 트리거용 (실제 배포는 서버에서 수행) + stage('Checkout develop (trigger only)') { + steps { + git branch: "${env.GIT_BRANCH}", + url: "${env.GIT_URL}", + credentialsId: 'gitlab_token' + } + } + + stage('Deploy (pull → app down → build → app up → react build → nginx down/up)') { + steps { + sh """ + set -eo pipefail + + ssh -i /var/jenkins_home/.ssh/id_rsa -o StrictHostKeyChecking=no ${BACKEND_USER}@${BACKEND_HOST} ' + set -eo pipefail + echo "[1] cd ${APP_PATH}" + cd ${APP_PATH} + + echo "[2] git pull origin ${GIT_BRANCH}" + git pull origin ${GIT_BRANCH} + + echo "[3] ensure edge-net" + docker network create edge-net 2>/dev/null || true + + echo "[4] app down (spring/fastapi) — 요청대로 먼저 내림" + docker-compose -f ${COMPOSE_APP} down spring fastapi || true + + echo "[5] build images (spring/fastapi)" + docker-compose -f ${COMPOSE_APP} build --pull spring fastapi + + echo "[6] app up -d (spring/fastapi)" + docker-compose -f ${COMPOSE_APP} up -d spring fastapi + + echo "[7] react one-off build (dist 생성)" + docker-compose -f ${COMPOSE_APP} run --rm react sh -lc " + set -e + if command -v corepack >/dev/null 2>&1; then corepack enable || true; fi + if [ -f pnpm-lock.yaml ]; then + command -v pnpm >/dev/null 2>&1 || npm i -g pnpm + pnpm install --frozen-lockfile || pnpm install + pnpm run build + elif [ -f yarn.lock ]; then + yarn install --frozen-lockfile || yarn install + yarn build + else + npm ci || npm install + npm run build + fi + " + + echo "[8] nginx down → up (정적파일 갱신 반영)" + docker-compose -f ${COMPOSE_NGINX} down || true + docker-compose -f ${COMPOSE_NGINX} up -d + + echo "[9] status" + docker ps --format "table {{.Names}}\\t{{.Image}}\\t{{.Status}}" + + echo "✅ done" + ' + """ + } + } + } + + post { + success { echo "🎉 배포 성공 (down→build→up + react build + nginx 재기동)" } + failure { echo "❌ 배포 실패 — 콘솔 로그에서 어느 단계에서 멈췄는지 확인" } + } +} +``` + +### 5.6. 문제 해결 체크리스트 +| 상황 | 점검 항목 | +| --- | --- | +| 컨테이너 기동 실패 | `docker logs {name}`, `.env` 누락, 포트 충돌 | +| DB 연결 오류 | `sudo docker-compose db` 상태, `.env` DB 자격 증명 | +| SSL 갱신 실패 | certbot 로그, `/etc/letsencrypt` 퍼미션 | +| 프론트 정적 파일 미노출 | `gg_frontend/dist` 존재 여부, Nginx volume 경로 | +| 모델 추천 오류 | `gg_recommend/app/data/artifacts` 유무, trainer 로그 | +| AI Server 오류 | `AI-server/.env` 설정, MongoDB 연결, LLM 엔드포인트 확인 | +| Jenkins 빌드 실패 | GitLab credentials, SSH 키 권한, Disk 용량, Docker 권한 에러 | +| Jenkins 배포 실패 | 서버와 SSH 연결 여부, docker-compose 파일 경로, compose syntax 오류 | + +## 6. 부록 + +### 6.1. 주요 경로/파일 +- `exec/01_빌드_및_배포_가이드.md`: 본 문서. +- `exec/02_외부_서비스_정보.md`: Steam API/OpenID/AI Service. +- `exec/03_시연_시나리오.md`: 시연 동선. +- `exec/04_DB_덤프_복원_가이드.md`: DB 덤프 파일 복원. +- `exec/db_dump.sql(.gz)`: 실제 덤프 파일. + +### 6.2. 참고 명령 +```bash +# 컨테이너 정보 +docker inspect {container} + +# 네트워크 확인 +docker network inspect edge-net + +# 디스크/메모리 +df -h +free -h +docker system df + +# 정리 +docker system prune -a +docker volume prune +``` diff --git "a/exec/02_\354\231\270\353\266\200_\354\204\234\353\271\204\354\212\244_\354\240\225\353\263\264.md" "b/exec/02_\354\231\270\353\266\200_\354\204\234\353\271\204\354\212\244_\354\240\225\353\263\264.md" new file mode 100644 index 0000000..943f1b8 --- /dev/null +++ "b/exec/02_\354\231\270\353\266\200_\354\204\234\353\271\204\354\212\244_\354\240\225\353\263\264.md" @@ -0,0 +1,93 @@ +# 2. 외부 서비스 정보 + +## 2.1. Steam API + +### 서비스 개요 +Steam Web API를 사용하여 게임 정보, 사용자 정보, 플레이어 통계 등을 수집합니다. + +### 가입 및 API 키 발급 방법 +1. **Steam 계정 필요**: Steam 계정이 있어야 합니다. +2. **Steam Web API 키 발급**: + - https://steamcommunity.com/dev/apikey 접속 + - 도메인 이름 입력 (예: `localhost` 또는 실제 도메인) + - API 키 발급 받기 + +### API 키 설정 위치 +- **Backend**: `gg_backend/.env` 파일의 `STEAM_API_KEY` 변수 +- **Ingester**: `gg_ingester/.env` 파일의 `STEAM_API_KEY` 변수 + +### 사용 API 엔드포인트 +1. **GetPlayerSummaries**: 사용자 프로필 정보 조회 + - 엔드포인트: `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/` + +2. **GetOwnedGames**: 사용자가 보유한 게임 목록 조회 + - 엔드포인트: `http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/` + +3. **GetGameDetails**: 게임 상세 정보 조회 + - 엔드포인트: `http://store.steampowered.com/api/appdetails/` + - 엔드포인트: `https://steamspy.com/api.php?request=appdetails` + +### 사용량 제한 +- Steam Web API는 일일 요청 제한이 있습니다. +- Ingester 서비스에서 Rate Limiting을 구현하여 제한을 준수합니다. +- 설정값: `COLLECTOR_TARGET_RLR=0.015` (1.5% 요청 실패율 목표) + +### 참고 문서 +- 공식 문서: https://steamcommunity.com/dev +- API 키 관리: https://steamcommunity.com/dev/apikey + +## 2.2. Steam OpenID 인증 + +### 서비스 개요 +Steam OpenID를 사용하여 사용자 로그인 인증을 제공합니다. + +### 설정 방법 +1. **도메인 설정**: Steam API 키 발급 시 사용한 도메인과 일치해야 합니다. +2. **콜백 URL 설정**: + - `gg_backend/.env` 파일에서 설정 + - `DOMAIN_URL`: 인증 요청 시 사용할 도메인 (예: `https://goodgamefinder.xyz`) + - `CALLBACK_URL`: Steam 인증 후 리다이렉트될 URL (예: `http://goodgamefinder.xyz/api/auth/steam/callback`) + - `REDIRECT_URL`: 최종 리다이렉트 URL (예: `http://goodgamefinder.xyz`) + +### 인증 흐름 +1. 사용자가 "Steam으로 로그인" 버튼 클릭 +2. `/api/auth/steam/login` 엔드포인트 호출 +3. Steam OpenID 로그인 페이지로 리다이렉트 +4. 사용자가 Steam 계정으로 로그인 +5. Steam이 콜백 URL로 리다이렉트 (`/api/auth/steam/callback`) +6. Steam ID 추출 및 JWT 토큰 발급 +7. 최종 리다이렉트 URL로 이동 + +### 설정 파일 위치 +- `gg_backend/src/main/resources/application.properties`: + ```properties + steam.api.key=${STEAM_API_KEY} + steam.realm=${DOMAIN_URL} + steam.callback=${CALLBACK_URL} + steam.redirect=${REDIRECT_URL} + ``` + +### 참고 문서 +- Steam OpenID: https://steamcommunity.com/dev +- OpenID 2.0 스펙: http://openid.net/specs/openid-authentication-2_0.html + +## 2.3. 외부 서비스 요약 + +| 서비스명 | 용도 | API 키 필요 | 무료 여부 | 제한 사항 | +|---------|------|------------|----------|----------| +| Steam Web API | 게임 정보, 사용자 정보 수집 | ✅ 필요 | ✅ 무료 | 요청 제한 있음 | +| Steam OpenID | 사용자 로그인 인증 | ✅ 필요 | ✅ 무료 | 도메인 등록 필요 | + +## 2.4. 외부 서비스 설정 체크리스트 + +배포 전 다음 사항을 확인하세요: + +- [ ] Steam Web API 키 발급 완료 +- [ ] Steam API 키를 Backend `.env` 파일에 설정 +- [ ] Steam API 키를 Ingester `.env` 파일에 설정 +- [ ] Steam OpenID 도메인 설정 완료 +- [ ] `DOMAIN_URL`, `CALLBACK_URL`, `REDIRECT_URL` 설정 확인 +- [ ] 프로덕션 환경의 경우 HTTPS 설정 확인 +- [ ] Steam API 요청 제한 준수를 위한 Rate Limiting 설정 확인 + + diff --git "a/exec/03_\354\213\234\354\227\260_\354\213\234\353\202\230\353\246\254\354\230\244.md" "b/exec/03_\354\213\234\354\227\260_\354\213\234\353\202\230\353\246\254\354\230\244.md" new file mode 100644 index 0000000..a4b4f7c --- /dev/null +++ "b/exec/03_\354\213\234\354\227\260_\354\213\234\353\202\230\353\246\254\354\230\244.md" @@ -0,0 +1,104 @@ +# 3. 시연 시나리오 + +## 3.1. 시연 환경 준비 + +### 접속 정보 +- **웹사이트 URL**: `http://goodgamefinder.xyz` +- **Steam 계정**: 시연용 Steam 계정 준비 (게임 보유 권장) + +--- + +## 3.2. 발표 스크립트 기반 시연 흐름 + +아래 흐름은 발표자가 그대로 읽어도 자연스러운 스크립트를 **행동 지침**과 함께 정리한 것입니다. + +### 1. 랜딩 페이지 소개 +- 멘트: “GG 서비스를 이용해보도록 하겠습니다. 처음 홈페이지를 들어온 이용자도 쉽게 사용할 수 있도록 구성했습니다.” +- 액션: 홈(`http://goodgamefinder.xyz`) 접속 → 히어로 섹션·맞춤 추천 섹션이 화면에 보이도록 스크롤 조정. + +### 2. 로그인 유도 +- 멘트: “먼저 로그인을 해볼까요?” +- 액션: 맞춤 게임 추천 섹션의 “로그인” 버튼 클릭 → Steam OpenID 창 → 테스트 계정으로 로그인 → 메인으로 자동 복귀했는지 확인. + +### 3. 메인 추천 섹션 강조 +- 멘트 예시 + ``` + 하이브리드 추천으로 개개인 맞춤형 추천을 받을 수 있습니다. + OTT 서비스를 이용하다 보면 ‘왜 이걸 나에게 추천했지?’ 싶은데, + 저희는 ‘추천 이유’까지 세세하게 확인할 수 있습니다. + ``` +- 액션: 메인 추천 카드의 “추천 이유” 버튼에 마우스를 올려 툴팁/설명 표시. + +### 4. 랭킹 섹션 소개 +- 멘트: “화면을 더 내려보면 게임 랭킹 시스템이 있어 현재 플레이어 수, 가격 정보를 한눈에 볼 수 있습니다. 스틸컷을 보며 취향에 맞는 게임을 찾을 수도 있겠죠?” +- 액션: 랭킹 섹션까지 스크롤 → 카드의 가격·플레이어 수 정보를 포인터로 강조. + +### 5. 게임 상세 화면 체험 +- 멘트: “게임 하나를 클릭해볼까요? (예: 배틀그라운드)” + “Steam처럼 미디어부터 세부정보까지 한눈에 볼 수 있고, 하단에는 다른 사용자가 남긴 리뷰가 있어 추천에 도움을 받을 수 있습니다.” +- 액션: 랭킹/추천 카드 중 하나 클릭 → 상세 페이지에서 미디어·정보·리뷰 섹션 차례로 스크롤. + +### 6. 전체 게임/검색 기능 +- 멘트: “또한 장르·가격·평점 등 다양한 기준으로 게임을 찾을 수 있도록 분류했습니다.” +- 액션: 헤더의 `전체 게임` 클릭 → 필터 패널(장르/태그/가격 등) 조작 예시 → 검색창에 키워드 입력. + +### 7. AI 자연어 검색 데모 +- 멘트: “이제 AI 추천 시스템을 이용해볼까요? ‘내가 액션 게임을 좋아하는데, 잔인한 건 못하겠어’처럼 자연어로 검색하면 취향에 맞는 게임을 찾을 수 있습니다.” +- 액션: 검색창에 문장 입력 → 결과 리스트 확인 → 상위 카드 클릭. + +### 8. 라이브러리 & 프로필 +- 멘트: + ``` + 라이브러리에서 내가 구매한 게임과 플레이 시간을 확인할 수 있고, + 개인 프로필에서는 보유 게임 비율 등 활동 통계를 볼 수 있습니다. + ``` +- 액션: 프로필 드롭다운 → `라이브러리` → 탭 전환(`라이브러리/위시리스트`) + → 다시 프로필 메인에서 활동 통계, 선호 장르 그래프 등 보여줌. + +### 9. 위시리스트/리뷰 +- 멘트: “원하는 게임은 상세 페이지에서 위시리스트에 추가하거나 리뷰를 작성해 다른 이용자에게 도움을 줄 수 있습니다.” +- 액션: 아무 게임 상세 페이지 → 위시리스트 버튼 ON/OFF → 리뷰 작성 버튼 클릭 후 폼 구조 보여주기(실제 작성은 선택). + +### 10. Akinator(탐정 다람G) 체험 +- 멘트: + ``` + 마지막으로 아키네이터 시연을 하고 GG 시연을 마치겠습니다. + 탐정 다람G가 스무고개 형식으로 이용자의 숨겨진 취향을 찾아드립니다. + ``` +- 액션: 헤더에서 `Akinator` 클릭 → “시작하기” → 질문 2~3개 답변 → 추천 결과 표시 → 추천 카드 클릭으로 상세 페이지 이동 예시. + +### 11. 마무리 +- 멘트: “이상으로 랜딩 → 추천 → 검색 → 라이브러리/프로필 → 아키네이터까지 GG 서비스의 주요 흐름을 살펴봤습니다.” + +--- + +## 3.3. 시연 시 주의사항 + +## 3.3. 시연 시 주의사항 + +### 기술적 주의사항 +- **인터넷 연결**: Steam API 호출을 위해 안정적인 인터넷 연결 필요 +- **로딩 시간**: 데이터 수집 및 추천 계산으로 인한 초기 로딩 시간 안내 +- **에러 처리**: API 오류 시 적절한 에러 메시지 표시 확인 + +### 시연 팁 +- **준비된 Steam 계정**: 게임을 보유한 Steam 계정 사용 권장 +- **주요 기능 강조**: 추천 시스템, Akinator 기능 등 핵심 기능 집중 시연 +- **사용자 경험 강조**: 직관적인 UI/UX, 빠른 응답 속도 등 + +### 예상 질문 대비 +- **추천 알고리즘**: 협업 필터링, 콘텐츠 기반 필터링 하이브리드 방식 설명 +- **데이터 수집**: Steam API를 통한 실시간 데이터 수집 및 업데이트 주기 +- **성능 최적화**: Redis 캐싱, 데이터베이스 인덱싱 등 최적화 기법 +- **확장성**: 마이크로서비스 아키텍처, Docker 컨테이너화를 통한 확장성 + +## 3.4. 시연 체크리스트 + +- [ ] 메인 추천/랭킹 섹션 최신 데이터 노출 +- [ ] Steam 로그인 세션 유지 시간 확인 +- [ ] 추천 이유 hover 툴팁 정상 동작 +- [ ] 상세 페이지 미디어/리뷰 로딩 확인 +- [ ] 필터·자연어 검색 결과 정상 +- [ ] 라이브러리/프로필 통계 데이터 최신화 +- [ ] 위시리스트/리뷰 버튼 동작 테스트 +- [ ] Akinator 질문/결과 흐름 점검 \ No newline at end of file diff --git "a/exec/04_DB_\353\215\244\355\224\204_\353\263\265\354\233\220_\352\260\200\354\235\264\353\223\234.md" "b/exec/04_DB_\353\215\244\355\224\204_\353\263\265\354\233\220_\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 0000000..63ebe32 --- /dev/null +++ "b/exec/04_DB_\353\215\244\355\224\204_\353\263\265\354\233\220_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,361 @@ +# 4. DB 덤프 파일 복원 가이드 + +## 4.1. 사전 준비 + +### 4.1.1. PostgreSQL 클라이언트 설치 확인 +```cmd +# PostgreSQL 클라이언트 버전 확인 +psql --version + +# 설치되어 있지 않은 경우 PostgreSQL 설치 필요 +# https://www.postgresql.org/download/windows/ +``` + +### 4.1.2. 덤프 파일 확인 +모든 테이블 덤프는 `exec/dump` 폴더에 있습니다. +```cmd +# exec/dump 폴더의 덤프 파일 확인 +dir exec\dump\*.sql + +# 개별 파일 확인 (필수 6종) +dir exec\dump\games.sql +dir exec\dump\attributes_.sql +dir exec\dump\medias.sql +dir exec\dump\game_attributes.sql +dir exec\dump\questions.sql +dir exec\dump\answers.sql +``` + +### 4.1.3. 데이터베이스 연결 확인 +```cmd +# PostgreSQL 서버 연결 테스트 +psql -h localhost -p 5432 -U root -d postgres -c "SELECT version();" + +# 또는 대화형 모드로 접속 +psql -h localhost -p 5432 -U root -d postgres +``` + +## 4.2. 덤프 파일 복원 방법 + +### 4.2.1. 기존 데이터베이스가 없는 경우 (최초 배포) + +#### 1단계: 데이터베이스 생성 +```cmd +# 데이터베이스 생성 +psql -h localhost -p 5432 -U root -d postgres -c "CREATE DATABASE GG;" + +# 데이터베이스 생성 확인 +psql -h localhost -p 5432 -U root -d postgres -c "\l" | findstr GG +``` + +#### 2단계: 덤프 파일 복원 (순서 중요!) + +**⚠️ 중요: 외래키 제약조건 때문에 반드시 아래 순서대로 복원해야 합니다.** + +```cmd +# 1단계: games (가장 먼저) +psql -h localhost -p 5432 -U root -d GG < exec\dump\games.sql + +# 2단계: attributes_, medias (순서 무관, 병렬 가능) +psql -h localhost -p 5432 -U root -d GG < exec\dump\attributes_.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\medias.sql + +# 3단계: game_attributes (위 테이블이 모두 존재해야 함) +psql -h localhost -p 5432 -U root -d GG < exec\dump\game_attributes.sql + +# 4단계: questions (독립 테이블) +psql -h localhost -p 5432 -U root -d GG < exec\dump\questions.sql + +# 5단계: answers (questions를 참조하므로 가장 마지막) +psql -h localhost -p 5432 -U root -d GG < exec\dump\answers.sql +``` + +**PowerShell 사용 시 (권장):** +```powershell +$files = @( + "games.sql", # 1 + "attributes_.sql", # 2 + "medias.sql", # 2 + "game_attributes.sql",# 3 + "questions.sql", # 4 + "answers.sql" # 5 +) + +foreach ($file in $files) { + Write-Host "복원 중: $file" -ForegroundColor Green + Get-Content "exec\dump\$file" -Encoding UTF8 | psql -h localhost -p 5432 -U root -d GG + if ($LASTEXITCODE -ne 0) { + Write-Host "오류 발생 → 중단" -ForegroundColor Red + break + } +} +``` + +#### 3단계: 복원 확인 +```cmd +# 테이블 목록 확인 +psql -h localhost -p 5432 -U root -d GG -c "\dt" + +# 각 테이블 데이터 개수 확인 +psql -h localhost -p 5432 -U root -d GG -c "SELECT COUNT(*) FROM games;" +psql -h localhost -p 5432 -U root -d GG -c "SELECT COUNT(*) FROM attributes;" +psql -h localhost -p 5432 -U root -d GG -c "SELECT COUNT(*) FROM medias;" +psql -h localhost -p 5432 -U root -d GG -c "SELECT COUNT(*) FROM game_attributes;" +``` + +### 4.2.2. 기존 데이터베이스가 있는 경우 (재배포/업데이트) + +#### 1단계: 기존 데이터베이스 백업 (선택사항) +```cmd +# 기존 데이터 백업 +pg_dump -h localhost -p 5432 -U root -d GG > exec\db_backup_%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%.sql + +# 또는 PowerShell 사용 시 +pg_dump -h localhost -p 5432 -U root -d GG > exec\db_backup_$(Get-Date -Format "yyyyMMdd_HHmmss").sql +``` + +#### 2단계: 기존 테이블 삭제 (선택사항) +```cmd +# 기존 테이블 삭제 (주의: 모든 데이터가 삭제됩니다) +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS game_attributes CASCADE;" +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS attributes CASCADE;" +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS medias CASCADE;" +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS games CASCADE;" +``` + +#### 3단계: 덤프 파일 복원 (순서 중요!) +```cmd +psql -h localhost -p 5432 -U root -d GG < exec\dump\games.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\attributes_.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\medias.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\game_attributes.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\questions.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\answers.sql +``` + +## 4.3. 복원 순서 상세 설명 + +### 4.3.1. 복원 순서의 이유 + +외래키/참조 관계 때문에 다음 순서를 반드시 지켜야 합니다: + +1. **games (1순위)** + - 모든 게임 관련 테이블의 기준 테이블 +2. **attributes_ + medias (2순위)** + - `game_attributes`가 `attributes_`를, `medias`가 `games`를 참조 +3. **game_attributes (3순위)** + - `games`와 `attributes_` 두 테이블을 모두 참조하는 M:N 관계 테이블 +4. **questions (4순위)** + - Akinator 기본 질문 목록 (다른 테이블 의존 없음) +5. **answers (5순위)** + - `questions`를 참조하므로 반드시 마지막 + +### 4.3.2. 외래키 제약조건 확인 + +복원 전에 덤프 파일에서 외래키 관계를 확인할 수 있습니다: + +**PowerShell 사용:** +```powershell +Select-String -Path exec\dump\game_attributes.sql -Pattern "FOREIGN KEY|REFERENCES" +Select-String -Path exec\dump\answers.sql -Pattern "FOREIGN KEY|REFERENCES" +``` + +**CMD 사용:** +```cmd +findstr /n /i "FOREIGN KEY REFERENCES" exec\dump\game_attributes.sql +findstr /n /i "FOREIGN KEY REFERENCES" exec\dump\answers.sql +``` + +## 4.4. 복원 후 검증 + +### 4.4.1. 데이터 무결성 확인 +```cmd +psql -h localhost -p 5432 -U root -d GG -c "\dt" + +psql -h localhost -p 5432 -U root -d GG -c ^ +"SELECT 'games' AS table_name, COUNT(*) AS count FROM games ^ +UNION ALL SELECT 'attributes_', COUNT(*) FROM attributes_ ^ +UNION ALL SELECT 'medias', COUNT(*) FROM medias ^ +UNION ALL SELECT 'game_attributes', COUNT(*) FROM game_attributes ^ +UNION ALL SELECT 'questions', COUNT(*) FROM questions ^ +UNION ALL SELECT 'answers', COUNT(*) FROM answers;" +``` + +### 4.4.2. 외래키 무결성 검증 +```cmd +# 외래키 제약조건 확인 +psql -h localhost -p 5432 -U root -d GG -c "SELECT conname, conrelid::regclass, confrelid::regclass FROM pg_constraint WHERE contype = 'f' AND conrelid::regclass::text IN ('game_attributes', 'medias');" + +# game_attributes의 외래키 무결성 검증 +psql -h localhost -p 5432 -U root -d GG -c "SELECT COUNT(*) FROM game_attributes ga LEFT JOIN games g ON ga.game_id = g.game_id LEFT JOIN attributes a ON ga.attribute_id = a.attribute_id WHERE g.game_id IS NULL OR a.attribute_id IS NULL;" +# 결과가 0이어야 함 (무결성 정상) +``` + +## 4.5. 문제 해결 + +### 4.5.1. 외래키 제약조건 오류 +**증상**: `ERROR: insert or update on table "game_attributes" violates foreign key constraint` + +**원인**: 복원 순서가 잘못되었거나, 참조하는 테이블이 아직 생성되지 않음 + +**해결 방법**: +```cmd +# 1. 외래키 제약조건 일시 비활성화 +psql -h localhost -p 5432 -U root -d GG -c "SET session_replication_role = 'replica';" + +# 2. 덤프 파일 재복원 (순서대로) +psql -h localhost -p 5432 -U root -d GG < exec\games.sql +psql -h localhost -p 5432 -U root -d GG < exec\attributes.sql +psql -h localhost -p 5432 -U root -d GG < exec\medias.sql +psql -h localhost -p 5432 -U root -d GG < exec\game_attributes.sql + +# 3. 외래키 제약조건 다시 활성화 +psql -h localhost -p 5432 -U root -d GG -c "SET session_replication_role = 'origin';" +``` + +### 4.5.2. 테이블이 이미 존재하는 오류 +**증상**: `ERROR: relation "games" already exists` + +**해결 방법**: +```cmd +# 기존 테이블 삭제 후 재복원 +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS game_attributes CASCADE;" +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS attributes CASCADE;" +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS medias CASCADE;" +psql -h localhost -p 5432 -U root -d GG -c "DROP TABLE IF EXISTS games CASCADE;" + +# 순서대로 재복원 +psql -h localhost -p 5432 -U root -d GG < exec\games.sql +psql -h localhost -p 5432 -U root -d GG < exec\attributes.sql +psql -h localhost -p 5432 -U root -d GG < exec\medias.sql +psql -h localhost -p 5432 -U root -d GG < exec\game_attributes.sql +``` + +### 4.5.3. 인코딩 문제 +**증상**: 한글 등 특수문자가 깨짐 + +**해결 방법**: +```cmd +# 데이터베이스 인코딩 확인 +psql -h localhost -p 5432 -U root -d GG -c "SHOW server_encoding;" +``` + +**PowerShell에서 인코딩 확인 및 복원:** +```powershell +# UTF-8 인코딩으로 복원 (권장) +Get-Content exec\games.sql -Encoding UTF8 | psql -h localhost -p 5432 -U root -d GG +Get-Content exec\attributes.sql -Encoding UTF8 | psql -h localhost -p 5432 -U root -d GG +Get-Content exec\medias.sql -Encoding UTF8 | psql -h localhost -p 5432 -U root -d GG +Get-Content exec\game_attributes.sql -Encoding UTF8 | psql -h localhost -p 5432 -U root -d GG +``` + +### 4.5.4. 권한 오류 +**증상**: `ERROR: permission denied` + +**해결 방법**: +```cmd +# 데이터베이스 소유자 확인 +psql -h localhost -p 5432 -U root -d postgres -c "\l GG" + +# 필요시 소유자 변경 +psql -h localhost -p 5432 -U root -d postgres -c "ALTER DATABASE GG OWNER TO root;" +``` + +### 4.5.5. 연결 오류 +**증상**: `FATAL: password authentication failed` 또는 `could not connect to server` + +**해결 방법**: +```cmd +# 1. PostgreSQL 서비스가 실행 중인지 확인 +sc query postgresql-x64-14 + +# 2. pg_hba.conf 파일 확인 (인증 방식 설정) +# 일반 위치: C:\Program Files\PostgreSQL\14\data\pg_hba.conf + +# 3. 연결 정보 확인 +# - 호스트: localhost 또는 127.0.0.1 +# - 포트: 기본값 5432 +# - 사용자명: root +# - 비밀번호: 환경 변수에서 확인 +``` + +## 4.6. 주의사항 + +1. **복원 순서 필수**: 외래키 제약조건 때문에 반드시 지정된 순서대로 복원해야 함 + - 1순위: `games` + - 2순위: `attributes`, `medias` (순서 무관) + - 3순위: `game_attributes` + +2. **데이터 백업**: 기존 데이터베이스가 있는 경우 반드시 백업 후 진행 + +3. **트랜잭션**: 각 덤프 파일 복원은 하나의 트랜잭션으로 실행되므로, 중간에 오류가 발생하면 해당 파일만 롤백됨 + +4. **용량**: 대용량 덤프 파일의 경우 복원 시간이 오래 걸릴 수 있음 + +5. **버전 호환성**: PostgreSQL 버전이 다른 경우 호환성 문제가 발생할 수 있음 + +6. **경로 구분자**: Windows에서는 `\` 사용, 하지만 psql 명령어에서는 `<` 리다이렉션 시 경로에 `\` 사용 가능 + +7. **인코딩**: Windows CMD는 기본적으로 CP949를 사용하므로, UTF-8 덤프 파일 복원 시 PowerShell 사용 권장 + +## 4.7. 빠른 복원 명령어 (요약) + +### CMD 기준 +```cmd +psql -h localhost -p 5432 -U root -d postgres -c "CREATE DATABASE GG;" + +psql -h localhost -p 5432 -U root -d GG < exec\dump\games.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\attributes_.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\medias.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\game_attributes.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\questions.sql +psql -h localhost -p 5432 -U root -d GG < exec\dump\answers.sql + +psql -h localhost -p 5432 -U root -d GG -c "\dt" +``` + +### PowerShell 기준 (권장) +```powershell +psql -h localhost -p 5432 -U root -d postgres -c "CREATE DATABASE GG;" +$files = @("games.sql","attributes_.sql","medias.sql","game_attributes.sql","questions.sql","answers.sql") +foreach ($file in $files) { + Get-Content "exec\dump\$file" -Encoding UTF8 | psql -h localhost -p 5432 -U root -d GG +} +psql -h localhost -p 5432 -U root -d GG -c "\dt" +``` + +## 4.8. 환경 변수 설정 (선택사항) + +매번 연결 정보를 입력하는 것이 번거로운 경우 환경 변수를 설정할 수 있습니다: + +```cmd +# CMD에서 환경 변수 설정 +set PGHOST=localhost +set PGPORT=5432 +set PGUSER=root +set PGDATABASE=GG + +# 이후 psql 명령어 간소화 +psql < exec\dump\games.sql +psql < exec\dump\attributes_.sql +psql < exec\dump\medias.sql +psql < exec\dump\game_attributes.sql +psql < exec\dump\questions.sql +psql < exec\dump\answers.sql +``` + +```powershell +# PowerShell에서 환경 변수 설정 +$env:PGHOST = "localhost" +$env:PGPORT = "5432" +$env:PGUSER = "root" +$env:PGDATABASE = "GG" + +# 이후 psql 명령어 간소화 +Get-Content exec\dump\games.sql -Encoding UTF8 | psql +Get-Content exec\dump\attributes_.sql -Encoding UTF8 | psql +Get-Content exec\dump\medias.sql -Encoding UTF8 | psql +Get-Content exec\dump\game_attributes.sql -Encoding UTF8 | psql +Get-Content exec\dump\questions.sql -Encoding UTF8 | psql +Get-Content exec\dump\answers.sql -Encoding UTF8 | psql +``` diff --git a/exec/README.md b/exec/README.md new file mode 100644 index 0000000..00a4c7f --- /dev/null +++ b/exec/README.md @@ -0,0 +1,50 @@ +# 포팅 매뉴얼 + +본 디렉토리는 SSAFY 13기 자율 프로젝트 포팅 매뉴얼을 포함합니다. + +## 문서 구성 + +1. **[01_빌드_및_배포_가이드.md](./01_빌드_및_배포_가이드.md)** + - Gitlab 소스 클론 이후 빌드 및 배포 방법 + - 사용한 JVM, 웹서버, WAS 제품 등의 종류와 설정 값, 버전 + - 빌드 시 사용되는 환경 변수 + - 배포 시 특이사항 + - DB 접속 정보 등 주요 계정 및 프로퍼티 파일 목록 + +2. **[02_외부_서비스_정보.md](./02_외부_서비스_정보.md)** + - 프로젝트에서 사용하는 외부 서비스 정보 + - Steam API 가입 및 활용 방법 + - Steam OpenID 인증 설정 방법 + - 외부 서비스 설정 체크리스트 + +3. **[03_시연_시나리오.md](./03_시연_시나리오.md)** + - 시연 순서에 따른 site 화면별, 실행별 상세 설명 + - 각 단계별 클릭 위치 및 확인 사항 + - 시연 시 주의사항 및 체크리스트 + +4. **[04_DB_덤프_복원_가이드.md](./04_DB_덤프_복원_가이드.md)** + - PostgreSQL 덤프 파일 복원 방법 + - 연관 관계(외래키)를 고려한 복원 순서 + - 복원 후 검증 및 문제 해결 + +5. **DB 덤프 파일** (별도 준비 필요) + - 필수 덤프: `exec/dump/` 내 `games.sql`, `attributes_.sql`, `medias.sql`, `game_attributes.sql`, `questions.sql`, `answers.sql` + - 복원 방법은 [04_DB_덤프_복원_가이드.md](./04_DB_덤프_복원_가이드.md)를 참고하세요. + +## 빠른 시작 + +1. `exec/dump` 폴더의 덤프 파일을 [04_DB_덤프_복원_가이드](./04_DB_덤프_복원_가이드.md)에 따라 순서대로 복원 +2. [01_빌드_및_배포_가이드](./01_빌드_및_배포_가이드.md)로 환경 변수 설정 및 Docker Compose 실행 +3. [02_외부_서비스_정보](./02_외부_서비스_정보.md) 내용을 참고해 Steam API/외부 서비스 자격 증명 설정 +4. [03_시연_시나리오](./03_시연_시나리오.md)를 활용해 데모 준비 + +## 주의사항 + +- `.env` 파일에는 실제 계정/비밀번호가 포함되므로 절대 커밋하지 마세요. +- 프로덕션 환경에서는 HTTPS(Nginx + Certbot) 설정이 필수입니다. +- Steam API 키와 데이터베이스 비밀번호 등 민감 정보는 팀 보안 규칙에 따라 공유하세요. +- 덤프 파일을 적용하기 전에 최신 버전인지 반드시 확인하세요. + +## 문의 + +프로젝트 관련 문의 사항은 팀원에게 연락해 주세요. diff --git a/exec/dump/answers.sql b/exec/dump/answers.sql new file mode 100644 index 0000000..51db934 --- /dev/null +++ b/exec/dump/answers.sql @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9a7f3076e3c4be11ac3416cbc9c45e2b9298bba8eabe3e391ff41b5b62eac94 +size 945 diff --git a/exec/dump/attributes_.sql b/exec/dump/attributes_.sql new file mode 100644 index 0000000..5b4e7c4 --- /dev/null +++ b/exec/dump/attributes_.sql @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d8f03b43432718a2b679f6f54e8dc8962e6b002a3c8853d00b85569e0803e07 +size 5166699 diff --git a/exec/dump/game_attributes.sql b/exec/dump/game_attributes.sql new file mode 100644 index 0000000..c5466f5 --- /dev/null +++ b/exec/dump/game_attributes.sql @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9242f264d464d7ab608aa60485dcc7fc739098dced1c5f9087d252e6e5923d77 +size 73371170 diff --git a/exec/dump/games.sql b/exec/dump/games.sql new file mode 100644 index 0000000..44655b2 --- /dev/null +++ b/exec/dump/games.sql @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7217027eddeaee076e4d640c337ecbc81122a38462c77345a9f6d669a4477d5b +size 56025634 diff --git a/exec/dump/medias.sql b/exec/dump/medias.sql new file mode 100644 index 0000000..6672304 --- /dev/null +++ b/exec/dump/medias.sql @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cc06f073a356ad91eeb2a3857242964ba618cf106c6d99554448ef53062ce27 +size 193975671 diff --git a/exec/dump/questions.sql b/exec/dump/questions.sql new file mode 100644 index 0000000..7337d94 --- /dev/null +++ b/exec/dump/questions.sql @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10e4d4a7260ccbfdc21e3038d8ed957a14ad4a017866358a32482acd23a309d8 +size 506 diff --git a/gg_web/.gitignore b/gg_web/.gitignore new file mode 100644 index 0000000..a6911de --- /dev/null +++ b/gg_web/.gitignore @@ -0,0 +1,35 @@ +# 환경변수 파일들 +.env +.env.local +.env.*.local + +# IDE 설정 +.vscode/ +.idea/ +.codex/ +*.swp +*.swo + +# OS 생성 파일 +.DS_Store +Thumbs.db + +# 로그 파일 +*.log +logs/ + +# 임시 파일 +*.tmp +*.temp + +# 백업 파일 +*.bak +*.backup + +# 압축 파일 +*.zip +*.tar.gz + +# 개발 중 테스트 파일 +test.* +debug.* diff --git a/gg_web/docker-compose.db.yml b/gg_web/docker-compose.db.yml new file mode 100644 index 0000000..ff35b89 --- /dev/null +++ b/gg_web/docker-compose.db.yml @@ -0,0 +1,70 @@ +services: + postgres: + image: postgres:18 + container_name: postgres + restart: unless-stopped + env_file: + - .env + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + - TZ=${TZ} + volumes: + - pgdata:/var/lib/postgresql/data + # init, pg_hba.conf, postgresql.conf 바인딩 없음 (기본값 사용) + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} -h 127.0.0.1"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - edge-net + + # PostgreSQL 메트릭 + postgres-exporter: + image: quay.io/prometheuscommunity/postgres-exporter:v0.15.0 + container_name: postgres_exporter + restart: unless-stopped + expose: + - "9187" + environment: + # 내부 DNS(서비스명)로 DB 연결: 불필요한 외부 왕복 방지 + DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" + depends_on: + - postgres + networks: + - edge-net + + # Redis + redis: + image: redis:7-alpine + container_name: redis + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} + env_file: + - .env + environment: + - TZ=${TZ} + volumes: + - redisdata:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - edge-net + +volumes: + pgdata: + redisdata: + +networks: + edge-net: + external: true + name: edge-net \ No newline at end of file diff --git a/gg_web/docker-compose.ingester.yml b/gg_web/docker-compose.ingester.yml new file mode 100644 index 0000000..5c89a63 --- /dev/null +++ b/gg_web/docker-compose.ingester.yml @@ -0,0 +1,19 @@ +services: + ingester: + build: ./gg_ingester + image: ingester-app:latest + container_name: gg_ingester + env_file: ./gg_ingester/.env + environment: + - TZ=${TZ} + expose: + - "8001" + restart: unless-stopped + networks: + - edge-net + +networks: + edge-net: + external: true + name: edge-net + diff --git a/gg_web/docker-compose.monitor.yml b/gg_web/docker-compose.monitor.yml new file mode 100644 index 0000000..942f4bc --- /dev/null +++ b/gg_web/docker-compose.monitor.yml @@ -0,0 +1,42 @@ +services: + # 1) 호스트 OS 메트릭 + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: node_exporter + restart: unless-stopped + network_mode: host # 9100 그대로 노출 + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host:ro,rslave + command: + - '--path.procfs=/host/proc' + - '--path.sysfs=/host/sys' + - '--path.rootfs=/host' + - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' + # 필요시 디스크 제외 패턴 추가 + + # 2) 컨테이너/도커 메트릭 + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.49.1 + container_name: cadvisor + restart: unless-stopped + expose: + - "8082" + privileged: true + command: + - "--port=8082" + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + networks: + - edge-net + +networks: + edge-net: + external: true + name: edge-net \ No newline at end of file diff --git a/gg_web/docker-compose.nginx.yml b/gg_web/docker-compose.nginx.yml new file mode 100644 index 0000000..7ebad78 --- /dev/null +++ b/gg_web/docker-compose.nginx.yml @@ -0,0 +1,31 @@ +services: + nginx: + image: nginx:1.27-alpine + container_name: edge-nginx + ports: + - "80:80" + - "443:443" + volumes: + - /etc/letsencrypt:/etc/letsencrypt:ro + - /var/www/certbot:/var/www/certbot:ro + - /etc/nginx/conf.d:/etc/nginx/conf.d:ro + - ./gg_frontend/dist:/usr/share/nginx/html:ro + networks: + - edge-net + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + + certbot: + image: certbot/certbot + container_name: certbot + volumes: + - /etc/letsencrypt:/etc/letsencrypt + - /var/www/certbot:/var/www/certbot + command: sleep infinity + networks: + - edge-net + +networks: + edge-net: + external: true diff --git a/gg_web/docker-compose.web.yml b/gg_web/docker-compose.web.yml new file mode 100644 index 0000000..69c357e --- /dev/null +++ b/gg_web/docker-compose.web.yml @@ -0,0 +1,50 @@ +services: + spring: + build: ./gg_backend + image: parkwj1998/spring-app:latest + container_name: gg_backend + env_file: ./gg_backend/.env + environment: + SPRING_PROFILES_ACTIVE: prod + # 예: DB/Redis 내부 호스트명 사용 + SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + SERVER_FORWARD_HEADERS_STRATEGY: framework + SERVER_TOMCAT_REMOTEIP_REMOTE_IP_HEADER: x-forwarded-for + SERVER_TOMCAT_REMOTEIP_PROTOCOL_HEADER: x-forwarded-proto + expose: + - "8080" + restart: unless-stopped + networks: + - edge-net + + fastapi: + build: ./gg_recommend + image: parkwj1998/fastapi-app:latest + container_name: gg_recommend + env_file: ./gg_recommend/.env + expose: + - "8000" + volumes: + - ./gg_recommend/app/data/artifacts:/app/data/artifacts + networks: + - edge-net + + react: + build: ./gg_frontend + image: parkwj1998/react-app:latest + container_name: gg_frontend + env_file: ./gg_frontend/.env + volumes: + # 소스/산출물 바인드 (dist가 호스트에 남음) + - ./gg_frontend:/app + working_dir: /app + restart: "no" + networks: + - edge-net + +networks: + edge-net: + external: true + name: edge-net diff --git a/gg_web/gg_backend/.dockerignore b/gg_web/gg_backend/.dockerignore new file mode 100644 index 0000000..f514388 --- /dev/null +++ b/gg_web/gg_backend/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +.gradle +build +**/.DS_Store +**/node_modules \ No newline at end of file diff --git a/gg_web/gg_backend/.gitattributes b/gg_web/gg_backend/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/gg_web/gg_backend/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/gg_web/gg_backend/.gitignore b/gg_web/gg_backend/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/gg_web/gg_backend/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/gg_web/gg_backend/Dockerfile b/gg_web/gg_backend/Dockerfile new file mode 100644 index 0000000..1cf5ad3 --- /dev/null +++ b/gg_web/gg_backend/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 + +############################ +# 1) Build stage (Gradle) +############################ +FROM gradle:8.10.2-jdk21-alpine AS build +WORKDIR /workspace + +# Gradle 캐시 최적화: 설정/의존성 먼저 복사 +COPY gradle/ gradle/ +COPY gradlew settings.gradle build.gradle ./ +# (버전 카탈로그 사용시) 아래 줄 주석 해제 +# COPY gradle/libs.versions.toml gradle/libs.versions.toml + +RUN chmod +x gradlew +# 의존성만 먼저 받아 캐시 확보 (실패해도 캐시 남도록 || true) +RUN ./gradlew --no-daemon dependencies || true + +# 실제 소스 복사 후 빌드 +COPY src/ src/ +RUN ./gradlew --no-daemon clean bootJar + +############################ +# 2) Runtime stage +############################ +FROM eclipse-temurin:21-jre + +WORKDIR /app + +# 보통 Spring Boot는 8080 사용 +EXPOSE 8080 + +# 운영용 기본 환경 +ENV TZ=Asia/Seoul \ + SPRING_PROFILES_ACTIVE=prod \ + JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom" + +# 빌드 산출물 복사 (생성되는 jar 이름 패턴에 맞춰 조정) +# build/libs 안에 하나만 나오도록 설정되어 있다면 * 로 충분 +COPY --from=build /workspace/build/libs/*.jar /app/app.jar + +RUN groupadd -r app && useradd -r -g app app +USER app + +ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar /app/app.jar"] diff --git a/gg_web/gg_backend/build.gradle b/gg_web/gg_backend/build.gradle new file mode 100644 index 0000000..780988b --- /dev/null +++ b/gg_web/gg_backend/build.gradle @@ -0,0 +1,87 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.6' + id 'io.spring.dependency-management' version '1.1.7' + id 'org.jetbrains.kotlin.jvm' +} + +group = 'com.gg' +version = '0.0.1-SNAPSHOT' +description = 'GG(Good Game Finder) project for Spring Boot' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + showCauses true + showExceptions true + showStackTraces true + } +} +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-security' + + implementation 'com.googlecode.json-simple:json-simple:1.1.1' + + // Swagger/OpenAPI + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + + // JWT (JJWT) + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Postgresql + runtimeOnly 'org.postgresql:postgresql' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Cache + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine:3.2.2' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' +// testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //QueryDSL 추가 + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' +} + +clean { + delete file('src/main/generated') +} diff --git a/gg_web/gg_backend/gradle/wrapper/gradle-wrapper.jar b/gg_web/gg_backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..ea3bc6d --- /dev/null +++ b/gg_web/gg_backend/gradle/wrapper/gradle-wrapper.jar @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172 +size 43764 diff --git a/gg_web/gg_backend/gradle/wrapper/gradle-wrapper.properties b/gg_web/gg_backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gg_web/gg_backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gg_web/gg_backend/gradlew b/gg_web/gg_backend/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/gg_web/gg_backend/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gg_web/gg_backend/gradlew.bat b/gg_web/gg_backend/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gg_web/gg_backend/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gg_web/gg_backend/settings.gradle b/gg_web/gg_backend/settings.gradle new file mode 100644 index 0000000..ee811fa --- /dev/null +++ b/gg_web/gg_backend/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { + plugins { + id 'org.jetbrains.kotlin.jvm' version '2.2.0' + } +} +rootProject.name = 'gg' diff --git a/gg_web/gg_backend/src/main/java/com/gg/GgApplication.java b/gg_web/gg_backend/src/main/java/com/gg/GgApplication.java new file mode 100644 index 0000000..74ff102 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/GgApplication.java @@ -0,0 +1,17 @@ +package com.gg; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +@EnableCaching +public class GgApplication { + + public static void main(String[] args) { + SpringApplication.run(GgApplication.class, args); + } + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/controller/AkinatorController.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/controller/AkinatorController.java new file mode 100644 index 0000000..ec96f54 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/controller/AkinatorController.java @@ -0,0 +1,48 @@ +package com.gg.application.akinator.controller; + +import com.gg.application.akinator.dto.request.UserAnswerRequestDto; +import com.gg.application.akinator.dto.response.AkinatorResultDto; +import com.gg.application.akinator.service.AkinatorService; +import com.gg.config.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping("/api/akinator") +@RequiredArgsConstructor +public class AkinatorController { + + private final AkinatorService akinatorService; + + @PostMapping("/question") + public ResponseEntity Question(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + try{ + return ResponseEntity.ok().body(akinatorService.getQuestions(customUserDetails.getUserId())); + }catch(Exception e){ + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + + } + + @PostMapping("/answers") + public ResponseEntity saveAllAnswers( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestBody List answers + ) { + try{ + AkinatorResultDto result = akinatorService.saveUserAnswers( + customUserDetails.getUserId(), + answers + ); + return ResponseEntity.ok(result); + }catch (Exception e){ + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/AnswerType.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/AnswerType.java new file mode 100644 index 0000000..c11927c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/AnswerType.java @@ -0,0 +1,89 @@ +package com.gg.application.akinator.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Getter +@AllArgsConstructor +public enum AnswerType { + // 선호도 설정 답변들 (1-17) + MULTIPLAYER_YES(1, builder -> builder.multiplayer(1.0f)), + MULTIPLAYER_NO(2, builder -> builder.multiplayer(0.0f)), + RECENT_RELEASE_YES(3, builder -> builder.recentRelease(1.0f)), + RECENT_RELEASE_NO(4, builder -> builder.recentRelease(0.0f)), + DIFFICULTY_EASY(5, builder -> builder.difficultyEasy(1.0f)), + DIFFICULTY_MEDIUM(6, builder -> builder.difficultyMedium(1.0f)), + DIFFICULTY_HARD(7, builder -> builder.difficultyHard(1.0f)), + PLAYTIME_SHORT(8, builder -> builder.playtimeShort(1.0f)), + PLAYTIME_MEDIUM(9, builder -> builder.playtimeMedium(1.0f)), + PLAYTIME_LONG(10, builder -> builder.playtimeLong(1.0f)), + GOAL_STORY(11, builder -> builder.goalStory(1.0f)), + GOAL_COMPETITION(12, builder -> builder.goalCompetition(1.0f)), + GOAL_BUILDING(13, builder -> builder.goalBuilding(1.0f)), + // 가격 선호도 (25-28) + PRICE_UNDER_10K(25, builder -> builder.gamePrice(10000)), + PRICE_10K_30K(26, builder -> builder.gamePrice(20000)), // 1만원~3만원 (중간값) + PRICE_30K_70K(27, builder -> builder.gamePrice(50000)), // 3만원~7만원 (중간값) + PRICE_OVER_70K(28, builder -> builder.gamePrice(70000)), // 7만원 이상 (최소값) + PRICE_FREE(24, builder -> builder.gamePrice(0)), // 무료 게임 + + // 게임 선택 답변들 (18-27) + GAME_LOL(14, null, + List.of("대규모 멀티플레이어", "전략", "액션"), + List.of("Competitive", "Multiplayer", "MOBA", "Strategy")), + GAME_FORTNITE(15, null, + List.of("액션", "슈팅", "생존", "대규모 멀티플레이어"), + List.of("Battle Royale", "Multiplayer", "Free to Play")), + GAME_MINECRAFT(16, null, + List.of("시뮬레이션", "샌드박스", "어드벤처"), + List.of("Sandbox", "Survival", "Exploration", "Creative")), + GAME_OVERWATCH(17, null, + List.of("액션", "슈팅", "대규모 멀티플레이어"), + List.of("Hero Shooter", "Team-Based", "Multiplayer", "FPS")), + GAME_ZELDA(18, null, + List.of("어드벤처", "액션", "RPG"), + List.of("Adventure", "Open World", "Fantasy", "Story Rich")), + GAME_FIFA(19, null, + List.of("스포츠", "시뮬레이션"), + List.of("Sports", "Simulation", "FIFA", "Multiplayer")), + GAME_AMONG_US(20, null, + List.of("파티 게임", "캐주얼", "대규모 멀티플레이어"), + List.of("Social Deduction", "Party Game", "Cooperative", "Multiplayer")), + GAME_TEMPEST(21, null, + List.of("액션", "어드벤처", "RPG"), + List.of("Racing", "Arcade", "Fast-Paced")), + GAME_POKEMON(22, null, + List.of("RPG", "어드벤처"), + List.of("RPG", "Adventure", "Collectathon", "Fantasy")), + GAME_WOW(23, null, + List.of("대규모 멀티플레이어", "RPG", "어드벤처"), + List.of("MMORPG", "RPG", "Fantasy", "Massively Multiplayer")); + + private final int answerId; + private final Consumer preferenceSetter; + private final List genres; + private final List tags; + + AnswerType(int answerId, Consumer preferenceSetter) { + this(answerId, preferenceSetter, List.of(), List.of()); + } + + private static final Map ANSWER_TYPE_MAP = + Arrays.stream(AnswerType.values()) + .collect(Collectors.toMap(AnswerType::getAnswerId, Function.identity())); + + public static AnswerType fromAnswerId(int answerId) { + return ANSWER_TYPE_MAP.get(answerId); + } + + public boolean isGameSelection() { + return answerId >= 14 && answerId <= 23; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/GameCriteria.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/GameCriteria.java new file mode 100644 index 0000000..cfd964d --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/GameCriteria.java @@ -0,0 +1,23 @@ +package com.gg.application.akinator.dto; + +import lombok.*; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +@Setter +@AllArgsConstructor +@EqualsAndHashCode +public class GameCriteria { + private Long id; + private String gameName; + private Integer gameInitialPrice; + private String headerImage; + private Integer peakCcu; + private LocalDate releaseDate; + private Integer averagePlaytimeForever; + private List genres; + private List tags; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/UserPreferences.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/UserPreferences.java new file mode 100644 index 0000000..4c8c46f --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/UserPreferences.java @@ -0,0 +1,48 @@ +package com.gg.application.akinator.dto; + +import lombok.Builder; +import lombok.Data; + + +/** + * 1. 게임 장르, 카테고리별로 나눠서 10개정도의 게임을 보여주고 여러개 고를 수 있도록 + * 2. 멀티플레이, 솔로플레잉 여부 + * 3. 최근 출시작 or 스테디셀러를 더 선호하는지 + * 4. 선호하는 게임 플랫폼 (PC, 콘솔, linux , mac) + * 5. 게임 난이도 + * 6. 총 플레이타임 (10시간 이하, 10-50시간, 50시간 이상) + * 7. 게임 내 목표 선호(스토리, 경쟁, 건설/창조) + * **/ + +@Data +@Builder +public class UserPreferences { + // 게임 장르 + Float genreAction; + Float genreStrategy; + + // 멀티플레이 여부 + Float multiplayer; + + // 최근 출시작 vs 스테디셀러 선호도 + Float recentRelease; + + // 게임 난이도 + Float difficultyEasy; + Float difficultyMedium; + Float difficultyHard; + + // 플레이 타임 + //TODO : 수정 + Float playtimeShort; + Float playtimeMedium; + Float playtimeLong; + + // 게임 내 목표 선호 + Float goalStory; + Float goalCompetition; + Float goalBuilding; + + // 게임 가격 + Integer gamePrice; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/request/UserAnswerRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/request/UserAnswerRequestDto.java new file mode 100644 index 0000000..f2fa929 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/request/UserAnswerRequestDto.java @@ -0,0 +1,12 @@ +package com.gg.application.akinator.dto.request; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UserAnswerRequestDto { + private Integer questionId; + private Integer answerId; +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/AkinatorResultDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/AkinatorResultDto.java new file mode 100644 index 0000000..8a5f992 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/AkinatorResultDto.java @@ -0,0 +1,13 @@ +package com.gg.application.akinator.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AkinatorResultDto{ + private Long gameId; + private String gameName; + private String headerImage; + private Integer gamePrice; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/AnswerDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/AnswerDto.java new file mode 100644 index 0000000..0f1aa77 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/AnswerDto.java @@ -0,0 +1,11 @@ +package com.gg.application.akinator.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AnswerDto { + private Integer id; + private String content; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/QuestionDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/QuestionDto.java new file mode 100644 index 0000000..ce0274d --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/dto/response/QuestionDto.java @@ -0,0 +1,15 @@ +package com.gg.application.akinator.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class QuestionDto { + private Integer id; + private String content; + private List answers; +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/Answers.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/Answers.java new file mode 100644 index 0000000..a92abdf --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/Answers.java @@ -0,0 +1,26 @@ +package com.gg.application.akinator.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "answers") +public class Answers { + + @Id + @Column(name = "answer_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id") + private Questions question; + + @Column(name = "content", columnDefinition = "text") + private String content; +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/Questions.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/Questions.java new file mode 100644 index 0000000..b8a4232 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/Questions.java @@ -0,0 +1,23 @@ +package com.gg.application.akinator.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "questions") +public class Questions { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL + @Column(name = "question_id") + private int id; + + @Column(name = "content", columnDefinition = "text") + private String content; +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/UserAnswers.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/UserAnswers.java new file mode 100644 index 0000000..9f651f1 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/UserAnswers.java @@ -0,0 +1,32 @@ +package com.gg.application.akinator.entity; + +import com.gg.application.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_answers") +public class UserAnswers { + + @EmbeddedId + private UserAnswersId id; + + @ManyToOne + @MapsId("userId") + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @MapsId("answerId") + @JoinColumn(name = "answer_id") + private Answers answer; + + @Builder + public UserAnswers(UserAnswersId id, User user, Answers answer) { + this.id = id; + this.user = user; + this.answer = answer; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/UserAnswersId.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/UserAnswersId.java new file mode 100644 index 0000000..5559216 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/entity/UserAnswersId.java @@ -0,0 +1,21 @@ +package com.gg.application.akinator.entity; + +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; + +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserAnswersId implements Serializable { + + private Integer userId; + private Integer answerId; + + @Builder + public UserAnswersId(Integer userId, Integer answerId) { + this.userId = userId; + this.answerId = answerId; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/AnswersRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/AnswersRepository.java new file mode 100644 index 0000000..df121fa --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/AnswersRepository.java @@ -0,0 +1,11 @@ +package com.gg.application.akinator.repository; + +import com.gg.application.akinator.entity.Answers; +import org.springframework.data.repository.CrudRepository; + +import java.util.List; + +public interface AnswersRepository extends CrudRepository { + List findAllByQuestion_Id(Integer id); + List findAllByOrderByQuestion_Id(); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/QuestionsRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/QuestionsRepository.java new file mode 100644 index 0000000..2e4271d --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/QuestionsRepository.java @@ -0,0 +1,12 @@ +package com.gg.application.akinator.repository; + +import com.gg.application.akinator.entity.Questions; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface QuestionsRepository extends JpaRepository { + List findAllByOrderById(); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/UserAnswersRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/UserAnswersRepository.java new file mode 100644 index 0000000..fb913a9 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/repository/UserAnswersRepository.java @@ -0,0 +1,24 @@ +package com.gg.application.akinator.repository; + +import com.gg.application.akinator.entity.UserAnswers; +import com.gg.application.akinator.entity.UserAnswersId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +public interface UserAnswersRepository extends JpaRepository { + + @Transactional + void deleteAllByUser_UserId(Integer userId); + + @Query("SELECT ua FROM UserAnswers ua " + + "JOIN FETCH ua.answer " + + "WHERE ua.user.userId = :userId") + List findAllByUser_UserId(Integer userId); + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/akinator/service/AkinatorService.java b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/service/AkinatorService.java new file mode 100644 index 0000000..2e7690b --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/akinator/service/AkinatorService.java @@ -0,0 +1,181 @@ +package com.gg.application.akinator.service; + +import com.gg.application.akinator.dto.AnswerType; +import com.gg.application.akinator.dto.GameCriteria; +import com.gg.application.akinator.dto.UserPreferences; +import com.gg.application.akinator.dto.request.UserAnswerRequestDto; +import com.gg.application.akinator.dto.response.AkinatorResultDto; +import com.gg.application.akinator.dto.response.AnswerDto; +import com.gg.application.akinator.dto.response.QuestionDto; +import com.gg.application.akinator.entity.Answers; +import com.gg.application.akinator.entity.Questions; +import com.gg.application.akinator.entity.UserAnswers; +import com.gg.application.akinator.entity.UserAnswersId; +import com.gg.application.akinator.repository.AnswersRepository; +import com.gg.application.akinator.repository.QuestionsRepository; +import com.gg.application.akinator.repository.UserAnswersRepository; +import com.gg.application.game.repository.GameRepository; +import com.gg.application.user.entity.User; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AkinatorService { + + @Autowired + private CacheManager cacheManager; + + @PersistenceContext + private EntityManager entityManager; + + private final QuestionsRepository questionsRepository; + private final AnswersRepository answersRepository; + private final UserAnswersRepository userAnswersRepository; + private final GameRepository gameRepository; + + /** + * 사용자가 API 호출 시 모든 질문과 선택지 리스트를 한 번에 리턴 + * + * @param userId 유저 ID 값 + * @return 모든 질문과 선택지들이 담긴 QuestionDto 리스트 + */ + public List getQuestions(Integer userId) { + //0. 이전 응답 정보 정리 (새로운 질문 시작 시) + userAnswersRepository.deleteAllByUser_UserId(userId); + + //1. 질문, 선택지 조회 + List allQuestions = questionsRepository.findAllByOrderById(); + List allAnswers = answersRepository.findAllByOrderByQuestion_Id(); + + //2. 질문별로 답변을 그룹화 + Map> answersByQuestionId = allAnswers.stream() + .collect(Collectors.groupingBy( + answer -> answer.getQuestion().getId(), + Collectors.mapping( + answer -> AnswerDto.builder() + .id(answer.getId()) + .content(answer.getContent()) + .build(), + Collectors.toList() + ) + )); + + //3. 질문과 답변을 함께 QuestionDto로 변환 + return allQuestions.stream() + .map(question -> QuestionDto.builder() + .id(question.getId()) + .content(question.getContent()) + .answers(answersByQuestionId.getOrDefault(question.getId(), Collections.emptyList())) + .build()) + .collect(Collectors.toList()); + } + + /** + * 사용자 응답들을 저장하고 결과를 리턴 + * + * @param userId 사용자 ID 값 + * @param answers 사용자가 선택한 질문-답변 리스트 + * @return 아키네이터 결과 + */ + public AkinatorResultDto saveUserAnswers(Integer userId, List answers) { + // 1. 유효성 검사 + if (answers == null || answers.isEmpty()) { + throw new RuntimeException("답변 리스트가 비어있습니다."); + } + + // 2. 모든 답변을 DB에 저장 + for (UserAnswerRequestDto answerDto : answers) { + if (answerDto.getQuestionId() == null || answerDto.getAnswerId() == null) { + throw new RuntimeException("questionId, answerId가 비었음"); + } + + UserAnswersId userAnswersId = UserAnswersId.builder() + .answerId(answerDto.getAnswerId()) + .userId(userId) + .build(); + + User user = entityManager.getReference(User.class, userId); + Answers answer = entityManager.getReference(Answers.class, answerDto.getAnswerId()); + + UserAnswers userAnswers = UserAnswers.builder() + .id(userAnswersId) + .user(user) + .answer(answer) + .build(); + + userAnswersRepository.save(userAnswers); + } + + // 3. 결과 계산 및 반환 + return akinatorResult(userId); + } + + /** + * 아키네이터 결과 + * + * @param userId 사용자의 ID 값 + * @return 사용자가 선택했던 답변들을 통해 추천 게임을 담은 리스트 반환 + * + */ + private AkinatorResultDto akinatorResult(Integer userId) { + long start = System.currentTimeMillis(); + log.debug("start time : {}", start); + AkinatorResultDto.AkinatorResultDtoBuilder akinatorResultDto = AkinatorResultDto.builder(); + try { + List userAnswers = userAnswersRepository.findAllByUser_UserId(userId); + Set selectedGenres = new HashSet<>(); + Set selectedTags = new HashSet<>(); + UserPreferences.UserPreferencesBuilder userPreferencesBuilder = UserPreferences.builder(); + + for (UserAnswers ua : userAnswers) { + AnswerType answerType = AnswerType.fromAnswerId(ua.getAnswer().getId()); + + // 사용자가 선택한 게임의 장르와 태그를 담음 + if (answerType.isGameSelection()) { + selectedGenres.addAll(answerType.getGenres()); + selectedTags.addAll(answerType.getTags()); + } + + if (answerType.getPreferenceSetter() != null) { + answerType.getPreferenceSetter().accept(userPreferencesBuilder); + } + } + + UserPreferences userPreferences = userPreferencesBuilder.build(); + + // 동적 필터 쿼리로 최적 게임 조회 + Optional bestGame = gameRepository.findBestGameByPreferences( + new ArrayList<>(selectedGenres), + new ArrayList<>(selectedTags), + userPreferences + ); + + if (bestGame.isPresent()) { + GameCriteria topGame = bestGame.get(); + return akinatorResultDto + .gameId(topGame.getId()) + .gameName(topGame.getGameName()) + .gamePrice(topGame.getGameInitialPrice()) + .headerImage(topGame.getHeaderImage()) + .build(); + } + return akinatorResultDto.build(); + } finally { + long endTime = System.currentTimeMillis(); + log.debug("timeDiff : {} 초", (endTime - start) / 1000.0); + } + } +} \ No newline at end of file diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/controller/AuthController.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/controller/AuthController.java new file mode 100644 index 0000000..2892644 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/controller/AuthController.java @@ -0,0 +1,52 @@ +package com.gg.application.auth.controller; + +import com.gg.application.auth.dto.request.DevLoginRequestDto; +import com.gg.application.auth.dto.request.DevSignupRequestDto; +import com.gg.application.auth.dto.request.RefreshRequestDto; +import com.gg.application.auth.dto.response.DevSignupResponseDto; +import com.gg.application.auth.dto.response.LoginResponseDto; +import com.gg.application.auth.dto.response.ReissueResponseDto; +import com.gg.application.auth.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/reissue") + public ResponseEntity reissue( + @RequestBody RefreshRequestDto refreshRequestDto + ){ + return ResponseEntity.ok(authService.reissue(refreshRequestDto)); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @RequestBody RefreshRequestDto refreshRequestDto + ){ + authService.logout(refreshRequestDto); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/login") + @Operation(summary = "개발용 로그인", description = "개발용 steamId로 로그인") + public ResponseEntity login(@RequestBody DevLoginRequestDto request) { + return ResponseEntity.ok(authService.login(request)); + } + + @PostMapping("/signup") + @Operation(summary = "개발용 회원가입", description = "개발용 steamId, userName으로 회원가입") + public ResponseEntity signup(@RequestBody DevSignupRequestDto req) { + return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req)); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/controller/SteamAuthController.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/controller/SteamAuthController.java new file mode 100644 index 0000000..f2aa613 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/controller/SteamAuthController.java @@ -0,0 +1,27 @@ +package com.gg.application.auth.controller; + +import com.gg.application.auth.service.SteamAuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +@RestController +@RequestMapping("/api/auth/steam") +@RequiredArgsConstructor +public class SteamAuthController { + + private final SteamAuthService steamAuthService; + + @GetMapping("/login") + public void redirectToSteam(HttpServletResponse response) throws IOException { + response.sendRedirect(steamAuthService.buildLoginUrl()); + } + + @GetMapping("/callback") + public void callback(HttpServletRequest req, HttpServletResponse res) throws Exception { + steamAuthService.handleCallback(req, res); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/DevLoginRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/DevLoginRequestDto.java new file mode 100644 index 0000000..3d9f5ed --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/DevLoginRequestDto.java @@ -0,0 +1,8 @@ +package com.gg.application.auth.dto.request; + +import lombok.Getter; + +@Getter +public class DevLoginRequestDto { + private Long steamId; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/DevSignupRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/DevSignupRequestDto.java new file mode 100644 index 0000000..f9a17bd --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/DevSignupRequestDto.java @@ -0,0 +1,9 @@ +package com.gg.application.auth.dto.request; + +import lombok.Getter; + +@Getter +public class DevSignupRequestDto { + private Long steamId; + private String userName; +} \ No newline at end of file diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/RefreshRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/RefreshRequestDto.java new file mode 100644 index 0000000..ee0bae1 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/request/RefreshRequestDto.java @@ -0,0 +1,9 @@ +package com.gg.application.auth.dto.request; + +import lombok.Getter; + +@Getter +public class RefreshRequestDto { + private Integer userId; + private String refreshToken; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/DevSignupResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/DevSignupResponseDto.java new file mode 100644 index 0000000..c4b8198 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/DevSignupResponseDto.java @@ -0,0 +1,8 @@ +package com.gg.application.auth.dto.response; + +public record DevSignupResponseDto( + Integer userId, + Long steamId, + String userName, + boolean reactivated +) {} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/LoginResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/LoginResponseDto.java new file mode 100644 index 0000000..0ef41f8 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/LoginResponseDto.java @@ -0,0 +1,15 @@ +package com.gg.application.auth.dto.response; + +import lombok.*; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponseDto { + private Integer userId; + private Long steamId; + private String userName; + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/ReissueResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/ReissueResponseDto.java new file mode 100644 index 0000000..eb78e5f --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/dto/response/ReissueResponseDto.java @@ -0,0 +1,11 @@ +package com.gg.application.auth.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ReissueResponseDto { + private String accessToken; + private String refreshToken; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/repository/AuthRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/repository/AuthRepository.java new file mode 100644 index 0000000..985c4ba --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/repository/AuthRepository.java @@ -0,0 +1,46 @@ +package com.gg.application.auth.repository; + +import com.gg.application.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface AuthRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = + "update users " + + "set refresh_token = :refreshToken " + + "where user_id = :userId", nativeQuery = true) + int updateRefreshToken(@Param("userId") Integer userId, @Param("refreshToken") String refreshToken); + + @Query(""" + select u.userId as userId, + u.steamId as steamId, + u.userName as userName, + u.refreshToken as refreshToken + from User u + where u.userId = :userId + """) + Optional findReissueByUserId(@Param("userId") Integer userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update User u set u.refreshToken = null where u.userId = :userId") + int clearRefreshTokenByUserId(@Param("userId") Integer userId); + + interface LoginProjection { + Integer getUserId(); + Long getSteamId(); + String getUserName(); + } + + interface ReissueProjection { + Integer getUserId(); + Long getSteamId(); + String getUserName(); + String getRefreshToken(); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/service/AuthService.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/service/AuthService.java new file mode 100644 index 0000000..b908771 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/service/AuthService.java @@ -0,0 +1,117 @@ +package com.gg.application.auth.service; + +import com.gg.application.auth.dto.request.DevLoginRequestDto; +import com.gg.application.auth.dto.request.DevSignupRequestDto; +import com.gg.application.auth.dto.request.RefreshRequestDto; +import com.gg.application.auth.dto.response.DevSignupResponseDto; +import com.gg.application.auth.dto.response.LoginResponseDto; +import com.gg.application.auth.dto.response.ReissueResponseDto; +import com.gg.application.auth.repository.AuthRepository; +import com.gg.application.auth.repository.AuthRepository.ReissueProjection; +import com.gg.application.user.entity.User; +import com.gg.application.user.repository.UserRepository; +import com.gg.common.jwt.JwtProvider; +import com.gg.common.jwt.JwtUserDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AuthRepository authRepository; + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + + @Transactional + public ReissueResponseDto reissue(RefreshRequestDto dto) { + ReissueProjection user = authRepository.findReissueByUserId(dto.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다.")); + + if(user.getRefreshToken() == null || !Objects.equals(user.getRefreshToken(), dto.getRefreshToken())){ + throw new IllegalArgumentException("유효하지 않은 refreshToken"); + } + + JwtUserDto jwtUserDto = new JwtUserDto(user.getUserId(), user.getSteamId(), user.getUserName()); + String accessToken = jwtProvider.generateToken(jwtUserDto); + String refreshToken = jwtProvider.generateRefreshToken(jwtUserDto); + + authRepository.updateRefreshToken(user.getUserId(), refreshToken); + + return new ReissueResponseDto(accessToken, refreshToken); + } + + public void logout(RefreshRequestDto dto) { + authRepository.clearRefreshTokenByUserId(dto.getUserId()); + } + + @Transactional + public DevSignupResponseDto signup(DevSignupRequestDto req) { + if (req.getSteamId() == null || req.getUserName() == null || req.getUserName().isBlank()) { + throw new IllegalArgumentException("steamId, userName은 필수입니다."); + } + + if (userRepository.findBySteamIdAndIsDeletedFalse(req.getSteamId()).isPresent()) { + throw new IllegalStateException("이미 가입된 사용자입니다."); + } + + Optional existing = userRepository.findBySteamId(req.getSteamId()); + + User user; + boolean reactivated = false; + + if (existing.isPresent()) { // 삭제된 계정이라면 복구 + user = existing.get(); + user.reactivate(req.getUserName()); + reactivated = true; + } else { // 없는 계정이라면 회원가입 + user = userRepository.save( + User.builder() + .steamId(req.getSteamId()) + .userName(req.getUserName()) + .build() + ); + } + + return new DevSignupResponseDto(user.getUserId(), user.getSteamId(), user.getUserName(), reactivated); + } + + @Transactional + public LoginResponseDto login(DevLoginRequestDto req) { + if (req.getSteamId() == null) { + throw new IllegalArgumentException("steamId는 필수입니다."); + } + + User user = userRepository.findBySteamId(req.getSteamId()) + .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 사용자입니다.")); + + if(user.isDeleted()){ + throw new IllegalArgumentException("삭제된 계정입니다. delete user"); + } + + return issueTokens(user); + } + + /** + * Access/Refresh 발급 + Refresh 저장 + */ + private LoginResponseDto issueTokens(User user) { + JwtUserDto principal = new JwtUserDto(user.getUserId(), user.getSteamId(), user.getUserName()); + String access = jwtProvider.generateToken(principal); + String refresh = jwtProvider.generateRefreshToken(principal); + + authRepository.updateRefreshToken(user.getUserId(), refresh); + + return LoginResponseDto.builder() + .userId(user.getUserId()) + .steamId(user.getSteamId()) + .userName(user.getUserName()) + .accessToken(access) + .refreshToken(refresh) + .build(); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/auth/service/SteamAuthService.java b/gg_web/gg_backend/src/main/java/com/gg/application/auth/service/SteamAuthService.java new file mode 100644 index 0000000..6419803 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/auth/service/SteamAuthService.java @@ -0,0 +1,103 @@ +package com.gg.application.auth.service; + +import com.gg.application.user.entity.User; +import com.gg.application.user.service.UserService; +import com.gg.application.auth.repository.AuthRepository; +import com.gg.common.jwt.JwtProvider; +import com.gg.common.jwt.JwtUserDto; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.servlet.http.HttpServletRequest; + +import java.time.Duration; +import java.util.*; +import java.util.regex.*; + +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class SteamAuthService { + + private final UserService userService; + private final AuthRepository authRepository; + private final JwtProvider jwtProvider; + + private final String STEAM_OPENID_URL = "https://steamcommunity.com/openid/login"; + + @Value("${steam.realm}") + private String realmUrl; + + @Value("${steam.callback}") + private String callbackUrl; + + @Value("${steam.redirect}") + private String redirectUrl; + + public String buildLoginUrl() { + return UriComponentsBuilder.fromHttpUrl(STEAM_OPENID_URL) + .queryParam("openid.ns", "http://specs.openid.net/auth/2.0") + .queryParam("openid.mode", "checkid_setup") + .queryParam("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select") + .queryParam("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select") + .queryParam("openid.return_to", callbackUrl) + .queryParam("openid.realm", realmUrl) + .toUriString(); + } + + @Transactional + public void handleCallback(HttpServletRequest req, HttpServletResponse res) throws Exception { + Map params = req.getParameterMap(); + + String claimedId = first(params, "openid.claimed_id"); // https://steamcommunity.com/openid/id/7656119... + Long steamId = extractSteamId(claimedId); + + // 유저 조회/생성 (이름은 Steam API로 조회) + User user = userService.findOrCreateBySteamId(steamId); + + // JWT 발급 + JwtUserDto jwtUser = new JwtUserDto(user.getUserId(), user.getSteamId(), user.getUserName()); + String access = jwtProvider.generateToken(jwtUser); + String refresh = jwtProvider.generateRefreshToken(jwtUser); + + authRepository.updateRefreshToken(user.getUserId(), refresh); + + ResponseCookie atCookie = ResponseCookie.from("at", access) + .httpOnly(false) + .secure(false) // HTTPS에서만 전송할지 여부; + .sameSite("Lax") // 크로스사이트 전송 제어: 기본은 Lax(대부분의 CSRF 시나리오에서 자동전송 차단) + .path("/") + .maxAge(Duration.ofMinutes(30)) + .build(); + + ResponseCookie rtCookie = ResponseCookie.from("rt", refresh) + .httpOnly(false) + .secure(false) + .sameSite("Lax") + .path("/") + .maxAge(Duration.ofDays(14)) + .build(); + + res.addHeader("Set-Cookie", atCookie.toString()); + res.addHeader("Set-Cookie", rtCookie.toString()); + + // URL에 아무 것도 싣지 않고 콜백 페이지로 이동 + res.sendRedirect(redirectUrl); + } + + private static String first(Map map, String key) { + String[] v = map.get(key); + return (v != null && v.length > 0) ? v[0] : null; + } + + private static Long extractSteamId(String claimed) { + Matcher m = Pattern.compile(".*/id/(\\d{17})$").matcher(claimed); + if (!m.find()) throw new IllegalArgumentException("Invalid Steam claimed_id: " + claimed); + return Long.parseLong(m.group(1)); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/controller/AttributeController.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/controller/AttributeController.java new file mode 100644 index 0000000..6f94a5c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/controller/AttributeController.java @@ -0,0 +1,27 @@ +package com.gg.application.game.controller; + +import com.gg.application.game.dto.response.AttributeResponseDto; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.service.AttributeService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/attribute") +@RequiredArgsConstructor +public class AttributeController { + + private final AttributeService attributeService; + + @GetMapping("/{type}") + public ResponseEntity> getAttributes(@PathVariable AttributeType type) { + List attributes = attributeService.getAttributes(type); + return ResponseEntity.ok(attributes); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/controller/GameController.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/controller/GameController.java new file mode 100644 index 0000000..b73acb0 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/controller/GameController.java @@ -0,0 +1,64 @@ +package com.gg.application.game.controller; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.dto.response.GameDetailResponseDto; +import com.gg.application.game.dto.response.GameListResponseDto; +import com.gg.application.game.dto.response.GameMediaResponseDto; +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.game.dto.response.PlayerRankingResponseDto; +import com.gg.application.game.service.GameService; +import com.gg.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/games") +@RequiredArgsConstructor +public class GameController { + + private final GameService gameService; + + @GetMapping("/{gameId}") + public ResponseEntity getGameDetail(@PathVariable long gameId) { + GameDetailResponseDto dto = gameService.getGameDetail(gameId); + return ResponseEntity.ok(dto); + } + + @GetMapping + @Operation(summary = "게임 목록", description = "게임 목록 조회(필터, 정렬, 검색)") + public ResponseEntity> getGames( + @ParameterObject GameListRequestDto req, + @Parameter(description = "페이지(0-base)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "정렬 키(rating|price|releaseDate|like|averagePlaytimeForever)") + @RequestParam(defaultValue = "releaseDate") String sort, + @Parameter(description = "정렬 방향(asc|desc)") + @RequestParam(defaultValue = "desc") String order, + @AuthenticationPrincipal CustomUserDetails user + ) { + PageResponse body = gameService.getGames(req, page, size, sort, order, user); + return ResponseEntity.ok(body); + } + + @GetMapping("/{gameId}/medias") + public ResponseEntity getGameMedia(@PathVariable long gameId) { + GameMediaResponseDto dto = gameService.getGameMedia(gameId); + return ResponseEntity.ok(dto); + } + + @GetMapping("/players/rankings") + @Operation(summary = "플레이어 수 랭킹", description = "Redis의 전체 랭킹에서 조회 (top100/top500/top1000은 전체 랭킹에서 필터링)") + public ResponseEntity> getPlayerRankings( + @Parameter(description = "랭킹 그룹 (top100|top500|top1000|all) - 전체 랭킹에서 해당 개수만큼만 조회") @RequestParam(defaultValue = "top100") String group, + @Parameter(description = "페이지(0-base)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size + ) { + PageResponse rankings = gameService.getPlayerRankings(group, page, size); + return ResponseEntity.ok(rankings); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/request/GameListRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/request/GameListRequestDto.java new file mode 100644 index 0000000..7e30288 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/request/GameListRequestDto.java @@ -0,0 +1,99 @@ +package com.gg.application.game.dto.request; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.Explode; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.enums.ParameterStyle; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@AllArgsConstructor +@Schema(name = "GameListRequest", description = "게임 목록 조회 검색/필터/정렬 파라미터") +public class GameListRequestDto { + + @Schema(description = "게임명 부분검색") + @Size(max = 50, message = "검색어는 50자 이하여야 합니다.") + private String q; + + @Schema(description = "최종가 하한") + private Integer minPrice; + + @Schema(description = "최종가 상한") + private Integer maxPrice; + + @Schema(description = "평점 하한") + private Float minRating; + + @Schema(description = "최소 할인율(%)") + private Integer discountFrom; + + @Schema(description = "출시일 하한", type = "string", format = "date") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate releasedFrom; + + @Schema(description = "출시일 상한", type = "string", format = "date") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate releasedTo; + + @ArraySchema(schema = @Schema(type = "integer")) + @Parameter( + description = "장르 ID (여러 개는 반복 또는 콤마)", + in = ParameterIn.QUERY, + array = @ArraySchema(schema = @Schema(type = "integer")), + style = ParameterStyle.FORM, // => ?categories=1&categories=2 + explode = Explode.TRUE + ) + private List genres; + + @ArraySchema(schema = @Schema(type = "integer")) + @Parameter( + description = "태그 ID (여러 개는 반복 또는 콤마)", + in = ParameterIn.QUERY, + array = @ArraySchema(schema = @Schema(type = "integer")), + style = ParameterStyle.FORM, // => ?categories=1&categories=2 + explode = Explode.TRUE + ) + private List tags; + + @ArraySchema(schema = @Schema(type = "integer")) + @Parameter( + description = "카테고리 ID (여러 개는 반복 또는 콤마)", + in = ParameterIn.QUERY, + array = @ArraySchema(schema = @Schema(type = "integer")), + style = ParameterStyle.FORM, // => ?categories=1&categories=2 + explode = Explode.TRUE + ) + private List categories; + + @ArraySchema(schema = @Schema(type = "string", allowableValues = { + "windows", "mac", "linux" + })) + @Parameter( + description = "지원 OS (windows|mac|linux), 여러 개 가능", + in = ParameterIn.QUERY, + array = @ArraySchema(schema = @Schema(type = "string")), + style = ParameterStyle.FORM, + explode = Explode.TRUE + ) + private List platforms; + + @ArraySchema(schema = @Schema(type = "string", allowableValues = { + "ko", "en", "ja", "zh-cn", "zh-tw" + })) + @Parameter( + description = "지원 언어 한국어, 영어, 일본어, 중국어 간체, 중국어 번체 (ko|en|ja|zh-cn|zh-tw), 여러 개 가능", + in = ParameterIn.QUERY, + array = @ArraySchema(schema = @Schema(type = "string")), + style = ParameterStyle.FORM, + explode = Explode.TRUE + ) + private List languages; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/AttributeResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/AttributeResponseDto.java new file mode 100644 index 0000000..adb773a --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/AttributeResponseDto.java @@ -0,0 +1,14 @@ +package com.gg.application.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AttributeResponseDto { + + private Integer attributeId; + private String attributeName; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameDetailResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameDetailResponseDto.java new file mode 100644 index 0000000..fb28c3f --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameDetailResponseDto.java @@ -0,0 +1,79 @@ +package com.gg.application.game.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@NoArgsConstructor +public class GameDetailResponseDto { + private Long gameId; + private String gameName; + private LocalDate releaseDate; + private int gameInitialPrice; + private int gameDiscount; + private int gameFinalPrice; + private String gameDescription; + private int positive; + private int negative; + private Boolean isWindow; + private Boolean isMac; + private Boolean isLinux; + private int averagePlaytime2weeks; + private int averagePlaytimeForever; + private int peakCcu; + private float rating; + private int likeCount; + + private List developers; + private List publishers; + private List genres; + private List tags; + private List categories; + private List languages; + + private String headerImage; + private List screenshots; + private List movies; + + public GameDetailResponseDto( + Long gameId, String gameName, LocalDate releaseDate, + int gameInitialPrice, int gameDiscount, int gameFinalPrice, + String gameDescription, int positive, int negative, + Boolean isWindow, Boolean isMac, Boolean isLinux, + int averagePlaytime2weeks, int averagePlaytimeForever, + int peakCcu, float rating, int likeCount, + List developers, List publishers, List genres, + List tags, List categories, List languages, + String headerImage, List screenshots, List movies + ) { + this.gameId = gameId; + this.gameName = gameName; + this.releaseDate = releaseDate; + this.gameInitialPrice = gameInitialPrice; + this.gameDiscount = gameDiscount; + this.gameFinalPrice = gameFinalPrice; + this.gameDescription = gameDescription; + this.positive = positive; + this.negative = negative; + this.isWindow = isWindow; + this.isMac = isMac; + this.isLinux = isLinux; + this.averagePlaytime2weeks = averagePlaytime2weeks; + this.averagePlaytimeForever = averagePlaytimeForever; + this.peakCcu = peakCcu; + this.rating = rating; + this.likeCount = likeCount; + this.developers = developers; + this.publishers = publishers; + this.genres = genres; + this.tags = tags; + this.categories = categories; + this.languages = languages; + this.headerImage = headerImage; + this.screenshots = screenshots; + this.movies = movies; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameIdNamePair.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameIdNamePair.java new file mode 100644 index 0000000..bc07be0 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameIdNamePair.java @@ -0,0 +1,14 @@ +package com.gg.application.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class GameIdNamePair { + + private Long gameId; + private String name; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameListResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameListResponseDto.java new file mode 100644 index 0000000..ead8e56 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameListResponseDto.java @@ -0,0 +1,23 @@ +package com.gg.application.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class GameListResponseDto { + private Long gameId; + private String gameName; + private Integer gameInitialPrice; + private Integer gameDiscount; + private Integer gameFinalPrice; + private Float rating; + private String headerImage; + + private List genres; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameMediaResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameMediaResponseDto.java new file mode 100644 index 0000000..6f8c23a --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/GameMediaResponseDto.java @@ -0,0 +1,19 @@ +package com.gg.application.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GameMediaResponseDto { + private Long gameId; + private String headerImage; + private List screenshots; + private List movies; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/PageResponse.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/PageResponse.java new file mode 100644 index 0000000..df41649 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/PageResponse.java @@ -0,0 +1,34 @@ +package com.gg.application.game.dto.response; + +import org.springframework.data.domain.Page; + +public class PageResponse { + public final Object content; + public final int page; + public final int size; + public final long totalElements; + public final int totalPages; + public final boolean hasNext; + public final boolean hasPrevious; + public final String sort; + public final String order; + + private PageResponse(Page p, String sort, String order) { + this.content = p.getContent(); + this.page = p.getNumber(); + this.size = p.getSize(); + this.totalElements = p.getTotalElements(); + this.totalPages = p.getTotalPages(); + this.hasNext = p.hasNext(); + this.hasPrevious = p.hasPrevious(); + this.sort = sort; + this.order = order; + } + + public static PageResponse of(Page p, String sort, String order) { + return new PageResponse<>(p, + (sort == null || sort.isBlank()) ? "releaseDate" : sort, + (order == null || order.isBlank()) ? "desc" : order + ); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/PlayerRankingResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/PlayerRankingResponseDto.java new file mode 100644 index 0000000..06c067c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/dto/response/PlayerRankingResponseDto.java @@ -0,0 +1,26 @@ +package com.gg.application.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class PlayerRankingResponseDto { + private Long gameId; + private Integer playerCount; + private Integer rank; + private String gameName; + private Integer gameInitialPrice; + private Integer gameDiscount; + private Integer gameFinalPrice; + private Float rating; + private String headerImage; + private List genres; +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Attribute.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Attribute.java new file mode 100644 index 0000000..8c0cd8e --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Attribute.java @@ -0,0 +1,25 @@ +package com.gg.application.game.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "attributes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Attribute { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attribute_id") + private int id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private AttributeType type; + + @Column(name = "name", nullable = false) + private String name; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/AttributeType.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/AttributeType.java new file mode 100644 index 0000000..7189332 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/AttributeType.java @@ -0,0 +1,5 @@ +package com.gg.application.game.entity; + +public enum AttributeType { + developer, publisher, genre, tag, category, language +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/CacheType.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/CacheType.java new file mode 100644 index 0000000..6a426bd --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/CacheType.java @@ -0,0 +1,42 @@ +package com.gg.application.game.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 캐시 타입을 정의하는 Enum + * 각 캐시 타입별로 다른 설정(TTL, 크기 등)을 적용할 수 있습니다. + */ +@Getter +@RequiredArgsConstructor +public enum CacheType { + /** + * 사용자 tag_filter_enabled 설정 캐시 + */ + TAG_FILTER("tag-filter", 30, 10000), + + /** + * 게임 상세 정보 캐시 + */ + GAME_DETAIL("game-detail", 60, 1000), + + /** + * 게임 미디어 정보 캐시 (headerImage, screenshots, movies) + */ + GAME_MEDIA("game-media", 120, 1000), + + /** + * 속성 목록 캐시 + */ + ATTRIBUTES("attributes", 720, 10), + + /** + * 게임 목록 count 캐시 + */ + GAME_COUNT("game-count", 30, 1000); + + private final String cacheName; + private final int expiredAfterWrite; // 분 단위 + private final int maximumSize; +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Game.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Game.java new file mode 100644 index 0000000..3839869 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Game.java @@ -0,0 +1,90 @@ +package com.gg.application.game.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "games") +@EntityListeners(AuditingEntityListener.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Game { + + @Id + @Column(name = "game_id") + private Long id; + + @Column(name = "game_name", columnDefinition = "text") + private String gameName; + + @Column(name = "release_date") + private LocalDate releaseDate; + + @Column(name = "game_initial_price") + private Integer gameInitialPrice; + + @Column(name = "game_discount") + private Integer gameDiscount; + + @Column(name = "game_final_price") + private Integer gameFinalPrice; + + @Column(name = "game_description", columnDefinition = "text") + private String gameDescription; + + @Column(name = "positive") + private Integer positive; + + @Column(name = "negative") + private Integer negative; + + @Column(name = "is_window", nullable = false, columnDefinition = "boolean default false") + private Boolean isWindow; + + @Column(name = "is_mac", nullable = false, columnDefinition = "boolean default false") + private Boolean isMac; + + @Column(name = "is_linux", nullable = false, columnDefinition = "boolean default false") + private Boolean isLinux; + + @Column(name = "average_playtime_2weeks") + private Integer averagePlaytime2weeks; + + @Column(name = "average_playtime_forever") + private Integer averagePlaytimeForever; + + @Column(name = "peak_ccu") + private Integer peakCcu; + + @Column(name = "avg_rating", nullable = false, columnDefinition = "float default 0.0") + private Float avgRating; + + @Column(name = "like_count", columnDefinition = "int default 0") + private Integer likeCount; + + @Column(name = "header_image", columnDefinition = "text") + private String headerImage; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "is_deleted", nullable = false, columnDefinition = "boolean default false") + private Boolean isDeleted; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GameAttribute.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GameAttribute.java new file mode 100644 index 0000000..51e43c7 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GameAttribute.java @@ -0,0 +1,26 @@ +package com.gg.application.game.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "game_attributes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GameAttribute { + + @EmbeddedId + private GameAttributeId id; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("gameId") + @JoinColumn(name = "game_id", nullable = false) + private Game game; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("attributeId") + @JoinColumn(name = "attribute_id", nullable = false) + private Attribute attribute; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GameAttributeId.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GameAttributeId.java new file mode 100644 index 0000000..ddba44a --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GameAttributeId.java @@ -0,0 +1,28 @@ +package com.gg.application.game.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.io.Serializable; +import java.util.Objects; + +@Embeddable +public class GameAttributeId implements Serializable { + + @Column(name = "game_id") private Long gameId; + @Column(name = "attribute_id") private int attributeId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof GameAttributeId)) return false; + GameAttributeId that = (GameAttributeId) o; + return Objects.equals(gameId, that.gameId) && + Objects.equals(attributeId, that.attributeId); + } + + @Override + public int hashCode() { + return Objects.hash(gameId, attributeId); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GamePlayer.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GamePlayer.java new file mode 100644 index 0000000..eea9323 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/GamePlayer.java @@ -0,0 +1,37 @@ +package com.gg.application.game.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "game_players") +public class GamePlayer { + + @Id + @Column(name = "game_id", nullable = false) + private Long gameId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "game_id") + private Game game; + + @Column(name = "player_count") + private Integer playerCount; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Media.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Media.java new file mode 100644 index 0000000..ee301c5 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/Media.java @@ -0,0 +1,29 @@ +package com.gg.application.game.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "medias") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Media { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "media_id") + private int id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "game_id") + private Game game; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private MediaType type; + + @Column(name = "url", columnDefinition = "text") + private String url; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/MediaType.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/MediaType.java new file mode 100644 index 0000000..6a0e3f3 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/entity/MediaType.java @@ -0,0 +1,5 @@ +package com.gg.application.game.entity; + +public enum MediaType { + screenshot, movie +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/AttributeRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/AttributeRepository.java new file mode 100644 index 0000000..124ad91 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/AttributeRepository.java @@ -0,0 +1,19 @@ +package com.gg.application.game.repository; + +import com.gg.application.game.dto.response.AttributeResponseDto; +import com.gg.application.game.entity.Attribute; +import com.gg.application.game.entity.AttributeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface AttributeRepository extends JpaRepository { + + @Query("select " + + "new com.gg.application.game.dto.response.AttributeResponseDto(a.id, a.name) " + + "from Attribute a " + + "where a.type = :type") + List findByType(@Param("type") AttributeType type); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameAttributeRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameAttributeRepository.java new file mode 100644 index 0000000..d6d733c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameAttributeRepository.java @@ -0,0 +1,21 @@ +package com.gg.application.game.repository; + +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.entity.GameAttribute; +import com.gg.application.game.entity.GameAttributeId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface GameAttributeRepository extends JpaRepository { + @Query(""" + select a.name + from GameAttribute ga + join ga.attribute a + where ga.game.id = :gameId + and a.type = :type + """) + List findAttributeNamesByGameIdAndType(@Param("gameId") Long gameId, @Param("type") AttributeType type); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepository.java new file mode 100644 index 0000000..7048d94 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepository.java @@ -0,0 +1,81 @@ +package com.gg.application.game.repository; + +import com.gg.application.game.dto.response.GameIdNamePair; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.entity.Game; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface GameRepository extends JpaRepository, GameRepositoryCustom { + + @Query("select g.headerImage " + + "from Game g " + + "where g.id = :gameId") + Optional findHeaderImageById(@Param("gameId") Long gameId); + + @Query(""" + select g.avgRating + from Game g + where g.id = :gameId + """) + Float findAvgRatingByGameId(@Param("gameId") Long gameId); + + @Query(""" + select g.id as gameId, g.avgRating as rating + from Game g + where g.id in :gameIds + """) + List findAvgRatingsByGameIds(@Param("gameIds") List gameIds); + + @Query(""" + select cast(round(coalesce(avg(r.rating), 0), 1) as float) + from Review r + where r.game.id = :gameId + """) + Float calculateAvgRatingFromReviews(@Param("gameId") Long gameId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Game g set g.avgRating = :rating, g.updatedAt = CURRENT_TIMESTAMP + where g.id = :gameId + """) + void updateAvgRating(@Param("gameId") Long gameId, @Param("rating") Float rating); + + @Query("select new com.gg.application.game.dto.response.GameIdNamePair(ga.id.gameId, a.name) " + + "from GameAttribute ga " + + "join Attribute a on a.id = ga.id.attributeId " + + "where ga.id.gameId in :gameIds " + + "and a.type = :type") + List findAttributeNamebyGameIdAndType(@Param("gameIds") List gameIds, @Param("type") AttributeType type); + + /** + * 게임의 좋아요 수를 1 증가시킵니다 (원자적 연산) + * + * @param gameId 게임 ID + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE games + SET like_count = like_count + 1, updated_at = NOW() + WHERE game_id = :gameId + """, nativeQuery = true) + void incrementLikeCount(@Param("gameId") Long gameId); + + /** + * 게임의 좋아요 수를 1 감소시킵니다 (원자적 연산) + * + * @param gameId 게임 ID + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE games + SET like_count = GREATEST(like_count - 1, 0), updated_at = NOW() + WHERE game_id = :gameId + """, nativeQuery = true) + void decrementLikeCount(@Param("gameId") Long gameId); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepositoryCustom.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepositoryCustom.java new file mode 100644 index 0000000..d1cb5c6 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepositoryCustom.java @@ -0,0 +1,24 @@ +package com.gg.application.game.repository; + +import com.gg.application.akinator.dto.GameCriteria; +import com.gg.application.akinator.dto.UserPreferences; +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.dto.response.GameDetailResponseDto; +import com.gg.application.game.dto.response.GameListResponseDto; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.NumberExpression; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface GameRepositoryCustom { + NumberExpression avgRating(); + Optional findGameBase(long gameId); + List findAttributes(long gameId); + Page searchGame(GameListRequestDto req, Pageable pageable, boolean applyFilter); + Set findAllowedAppIdsExcludingThreeTags(Set appIds, boolean applyFilter); + Optional findBestGameByPreferences(List selectedGenres, List selectedTags, UserPreferences userPreferences); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepositoryImpl.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepositoryImpl.java new file mode 100644 index 0000000..603dde5 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/GameRepositoryImpl.java @@ -0,0 +1,883 @@ +package com.gg.application.game.repository; + +import com.gg.application.akinator.dto.GameCriteria; +import com.gg.application.akinator.dto.UserPreferences; +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.dto.response.GameDetailResponseDto; +import com.gg.application.game.dto.response.GameListResponseDto; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.service.GameCacheService; +import com.gg.common.query.GameFilterPredicates; +import com.querydsl.core.BooleanBuilder; +import org.springframework.context.annotation.Lazy; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.ComparableExpressionBase; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +import java.util.*; +import java.util.stream.Collectors; + +import static com.gg.application.game.entity.QGame.game; +import static com.gg.application.game.entity.QGameAttribute.gameAttribute; +import static com.gg.application.game.entity.QAttribute.attribute; + + +@Repository +@Transactional(readOnly=true) +public class GameRepositoryImpl implements GameRepositoryCustom { + + private static final Logger log = LoggerFactory.getLogger(GameRepositoryImpl.class); + + private final JPAQueryFactory queryFactory; + private final GameFilterPredicates gameFilterPredicates; + @Lazy + private final GameCacheService gameCacheService; + + public GameRepositoryImpl( + JPAQueryFactory queryFactory, + GameFilterPredicates gameFilterPredicates, + @Lazy GameCacheService gameCacheService + ) { + this.queryFactory = queryFactory; + this.gameFilterPredicates = gameFilterPredicates; + this.gameCacheService = gameCacheService; + } + + @Override + public NumberExpression avgRating() { + return game.avgRating.coalesce(0.0f); + } + + @Override + public Set findAllowedAppIdsExcludingThreeTags(Set appIds, boolean applyFilter) { + if (appIds == null || appIds.isEmpty()) return Set.of(); + + return queryFactory + .select(game.id) + .from(game) + .where( + game.id.in(appIds) + .and(gameFilterPredicates.excludeGamesHavingAllThreeTags(applyFilter)) // applyFilter=false면 null → 무시됨 + ) + .fetch() + .stream() + .collect(Collectors.toSet()); + } + + @Override + public Optional findGameBase(long gameId) { + GameDetailResponseDto dto = queryFactory + .select(Projections.fields(GameDetailResponseDto.class, + game.id.as("gameId"), + game.gameName.as("gameName"), + game.releaseDate.as("releaseDate"), + game.gameInitialPrice.as("gameInitialPrice"), + game.gameDiscount.as("gameDiscount"), + game.gameFinalPrice.as("gameFinalPrice"), + game.gameDescription.as("gameDescription"), + game.positive.as("positive"), + game.negative.as("negative"), + game.isWindow.as("isWindow"), + game.isMac.as("isMac"), + game.isLinux.as("isLinux"), + game.averagePlaytime2weeks.as("averagePlaytime2weeks"), + game.averagePlaytimeForever.as("averagePlaytimeForever"), + game.peakCcu.as("peakCcu"), + game.avgRating.coalesce(0.0f).as("rating"), + game.likeCount.as("likeCount"), + game.headerImage.as("headerImage") + )) + .from(game) + .where(game.id.eq(gameId)) + .fetchOne(); + + // rating은 GameService에서 캐시에서 조회하여 설정 + return Optional.ofNullable(dto); + } + + @Override + public List findAttributes(long gameId) { + return queryFactory + .select(attribute.type, attribute.name) + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where(gameAttribute.id.gameId.eq(gameId)) + .fetch(); + } + + @Override + public Page searchGame(GameListRequestDto req, Pageable pageable, boolean applyFilter) { + Sort sort = pageable.getSort(); + BooleanBuilder where = buildWhereClause(req, applyFilter); + + if (req.getMinRating() != null) { + where.and(game.avgRating.goe(req.getMinRating())); + } + + String filterHash = generateFilterHash(req, applyFilter); + + Long total = gameCacheService.getCachedCount(filterHash); + + if (total == null) { + total = queryFactory + .select(game.id.count()) + .from(game) + .where(where) + .fetchOne(); + + if (total != null) { + gameCacheService.setCachedCount(filterHash, total); + } + } + + long totalCount = total == null ? 0L : total; + if (totalCount == 0L) { + return Page.empty(pageable); + } + + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.addAll(Arrays.asList(resolveOrder(sort, true))); + if (isRatingSort(sort)) { + orderSpecifiers.add(new OrderSpecifier<>(Order.DESC, game.releaseDate)); + orderSpecifiers.add(new OrderSpecifier<>(Order.ASC, game.id)); + } + + OrderSpecifier[] orderArray = orderSpecifiers.toArray(OrderSpecifier[]::new); + + List pageIds = queryFactory + .select(game.id) + .from(game) + .where(where) + .orderBy(orderArray) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + if (pageIds.isEmpty()) { + return Page.empty(pageable); + } + + List baseRows = queryFactory + .select( + game.id, + game.gameName, + game.gameInitialPrice, + game.gameDiscount, + game.gameFinalPrice, + game.headerImage, + game.avgRating + ) + .from(game) + .where(game.id.in(pageIds)) + .orderBy(orderArray) + .fetch(); + + Map rowMap = baseRows.stream() + .collect(Collectors.toMap( + row -> row.get(game.id), + row -> row + )); + + List orderedRows = pageIds.stream() + .map(rowMap::get) + .filter(Objects::nonNull) + .toList(); + + Map> genresMap = fetchGenresByGameIds(pageIds); + + List content = orderedRows.stream() + .map(row -> { + Long gameId = row.get(game.id); + Float rating = row.get(game.avgRating); + + return GameListResponseDto.builder() + .gameId(gameId) + .gameName(row.get(game.gameName)) + .gameInitialPrice(row.get(game.gameInitialPrice)) + .gameDiscount(row.get(game.gameDiscount)) + .gameFinalPrice(row.get(game.gameFinalPrice)) + .rating(rating != null ? rating : 0.0f) + .headerImage(row.get(game.headerImage)) + .genres(genresMap.getOrDefault(gameId, List.of())) + .build(); + }) + .toList(); + + return new PageImpl<>(content, pageable, totalCount); + } + + private BooleanBuilder buildWhereClause(GameListRequestDto req, boolean applyFilter) { + BooleanBuilder where = new BooleanBuilder(); + + where.and(gameFilterPredicates.excludeGamesHavingAllThreeTags(applyFilter)); + + // 기본 필터 조건 + if (req.getQ() != null && !req.getQ().isBlank()) { + where.and(game.gameName.containsIgnoreCase(req.getQ())); + } + if (req.getMinPrice() != null) { + where.and(game.gameFinalPrice.goe(req.getMinPrice())); + } + if (req.getMaxPrice() != null) { + where.and(game.gameFinalPrice.loe(req.getMaxPrice())); + } + if (req.getDiscountFrom() != null) { + where.and(game.gameDiscount.goe(req.getDiscountFrom())); + } + if (req.getReleasedFrom() != null) { + where.and(game.releaseDate.goe(req.getReleasedFrom())); + } + if (req.getReleasedTo() != null) { + where.and(game.releaseDate.loe(req.getReleasedTo())); + } + + // OS 필터 (AND 조건: 여러 개 선택 시 모두 지원해야 함) + if (req.getPlatforms() != null && !req.getPlatforms().isEmpty()) { + BooleanBuilder os = new BooleanBuilder(); + for (String p : req.getPlatforms()) { + if (p == null) continue; + switch (p.toLowerCase()) { + case "windows" -> os.and(game.isWindow.isTrue()); + case "mac" -> os.and(game.isMac.isTrue()); + case "linux" -> os.and(game.isLinux.isTrue()); + } + } + if (os.hasValue()) { + where.and(os); + } + } + + // 언어 필터 + if (req.getLanguages() != null && !req.getLanguages().isEmpty()) { + List normalized = normalizeLanguages(req.getLanguages()); + where.and(existsLanguageIn(normalized)); + } + + // 속성 필터 + if (req.getGenres() != null && !req.getGenres().isEmpty()) { + where.and(existsAttributeIn(AttributeType.genre, req.getGenres())); + } + if (req.getTags() != null && !req.getTags().isEmpty()) { + where.and(existsAttributeIn(AttributeType.tag, req.getTags())); + } + if (req.getCategories() != null && !req.getCategories().isEmpty()) { + where.and(existsAttributeIn(AttributeType.category, req.getCategories())); + } + + return where; + } + + private Map> fetchGenresByGameIds(List gameIds) { + if (gameIds.isEmpty()) { + return Map.of(); + } + + List genreRows = queryFactory + .select(gameAttribute.id.gameId, attribute.name) + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.in(gameIds) + .and(attribute.type.eq(AttributeType.genre)) + ) + .fetch(); + + return genreRows.stream() + .collect(Collectors.groupingBy( + row -> row.get(gameAttribute.id.gameId), + Collectors.mapping( + row -> row.get(attribute.name), + Collectors.toList() + ) + )); + } + + private boolean isRatingSort(Sort sort) { + if (sort == null || sort.isUnsorted()) { + return false; + } + return sort.stream() + .anyMatch(o -> "rating".equalsIgnoreCase(o.getProperty())); + } + + private BooleanExpression existsAttributeIn(AttributeType type, List attributeIds) { + if (attributeIds == null || attributeIds.isEmpty()) return null; + + // 여러 개 선택 시 AND 조건: 각 속성에 대해 개별 EXISTS 조건을 만들고 모두 AND로 연결 + if (attributeIds.size() == 1) { + // 단일 항목인 경우 기존 로직 사용 + return JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(type)) + .and(attribute.id.eq(attributeIds.get(0))) + ) + .exists(); + } else { + // 여러 항목인 경우: 각각에 대해 EXISTS 조건을 만들고 모두 AND로 연결 + BooleanExpression result = null; + for (Integer attributeId : attributeIds) { + BooleanExpression exists = JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(type)) + .and(attribute.id.eq(attributeId)) + ) + .exists(); + if (result == null) { + result = exists; + } else { + result = result.and(exists); + } + } + return result; + } + } + + private OrderSpecifier[] resolveOrder(Sort sort, boolean includeRating) { + if (sort == null || sort.isUnsorted()) { + return new OrderSpecifier[] { + new OrderSpecifier<>(Order.DESC, game.releaseDate) + }; + } + + return sort.stream() + .map(o -> { + Order order = o.isAscending() ? Order.ASC : Order.DESC; + String key = o.getProperty(); + // 🔽 rating 정렬은 리뷰 평균으로 + if ("rating".equalsIgnoreCase(key)) { + if (!includeRating) { + return null; + } + return new OrderSpecifier<>(order, avgRating()); + } + ComparableExpressionBase expr = toExpr(key); + if (expr == null) return null; + return new OrderSpecifier<>(order, expr); + }) + .filter(Objects::nonNull) + .toArray(OrderSpecifier[]::new); + } + + private ComparableExpressionBase toExpr(String key) { + if (key == null) return null; + switch (key.toLowerCase()) { + case "price": + case "gamefinalprice": return game.gameFinalPrice.coalesce(0); + case "releasedate": return game.releaseDate; + case "like": + case "likecount": return game.likeCount; + case "averageplaytimeforever": return game.averagePlaytimeForever; + default: return null; + } + } + + + private BooleanExpression existsLanguageIn(List languageNames) { + if (languageNames == null || languageNames.isEmpty()) return null; + + // 여러 개 선택 시 AND 조건: 각 언어에 대해 개별 EXISTS 조건을 만들고 모두 AND로 연결 + if (languageNames.size() == 1) { + // 단일 항목인 경우 기존 로직 사용 + return JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.language)) + .and(attribute.name.eq(languageNames.get(0))) + ) + .exists(); + } else { + // 여러 항목인 경우: 각각에 대해 EXISTS 조건을 만들고 모두 AND로 연결 + BooleanExpression result = null; + for (String languageName : languageNames) { + BooleanExpression exists = JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.language)) + .and(attribute.name.eq(languageName)) + ) + .exists(); + if (result == null) { + result = exists; + } else { + result = result.and(exists); + } + } + return result; + } + } + + private List normalizeLanguages(List langs) { + List result = new ArrayList<>(); + for (String lang : langs) { + if (lang == null) continue; + String lower = lang.toLowerCase(); + + switch (lower) { + case "en", "영어" -> result.add("영어"); + case "ko", "한국어" -> result.add("한국어"); + case "ja", "일본어" -> result.add("일본어"); + case "zh-cn", "중국어 간체" -> result.add("중국어 간체"); + case "zh-tw", "중국어 번체" -> result.add("중국어 번체"); + default -> result.add(lang); + } + } + return result; + } + + /** + * 필터 조건을 기반으로 캐시 키용 해시를 생성합니다. + * + * @param req 필터 요청 DTO + * @param applyFilter 태그 필터 적용 여부 + * @return 필터 조건의 해시값 + */ + private String generateFilterHash(GameListRequestDto req, boolean applyFilter) { + try { + StringBuilder sb = new StringBuilder(); + sb.append("filter:"); + sb.append("applyFilter=").append(applyFilter).append(";"); + + if (req.getQ() != null) sb.append("q=").append(req.getQ()).append(";"); + if (req.getMinPrice() != null) sb.append("minPrice=").append(req.getMinPrice()).append(";"); + if (req.getMaxPrice() != null) sb.append("maxPrice=").append(req.getMaxPrice()).append(";"); + if (req.getMinRating() != null) sb.append("minRating=").append(req.getMinRating()).append(";"); + if (req.getDiscountFrom() != null) sb.append("discountFrom=").append(req.getDiscountFrom()).append(";"); + if (req.getReleasedFrom() != null) sb.append("releasedFrom=").append(req.getReleasedFrom()).append(";"); + if (req.getReleasedTo() != null) sb.append("releasedTo=").append(req.getReleasedTo()).append(";"); + + if (req.getPlatforms() != null && !req.getPlatforms().isEmpty()) { + sb.append("platforms=").append(String.join(",", req.getPlatforms().stream().sorted().toList())).append(";"); + } + if (req.getLanguages() != null && !req.getLanguages().isEmpty()) { + sb.append("languages=").append(String.join(",", req.getLanguages().stream().sorted().toList())).append(";"); + } + if (req.getGenres() != null && !req.getGenres().isEmpty()) { + sb.append("genres=").append(req.getGenres().stream().sorted().map(String::valueOf).collect(Collectors.joining(","))).append(";"); + } + if (req.getTags() != null && !req.getTags().isEmpty()) { + sb.append("tags=").append(req.getTags().stream().sorted().map(String::valueOf).collect(Collectors.joining(","))).append(";"); + } + if (req.getCategories() != null && !req.getCategories().isEmpty()) { + sb.append("categories=").append(req.getCategories().stream().sorted().map(String::valueOf).collect(Collectors.joining(","))).append(";"); + } + + // SHA-256 해시 생성 + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(sb.toString().getBytes(StandardCharsets.UTF_8)); + + // 16진수 문자열로 변환 (처음 16자만 사용) + StringBuilder hexString = new StringBuilder(); + for (int i = 0; i < 8; i++) { + String hex = Integer.toHexString(0xff & hash[i]); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (Exception e) { + log.error("필터 해시 생성 실패", e); + // 실패 시 타임스탬프 기반 키 사용 (캐시 효과 없음) + return "fallback_" + System.currentTimeMillis(); + } + } + + @Override + public Optional findBestGameByPreferences(List selectedGenres, List selectedTags, UserPreferences userPreferences) { + // 필수 필터: 기본 조건 + 가격 + 장르/태그 + BooleanBuilder where = buildBasicWhereClause(userPreferences); + + // 장르/태그 필터 (OR 조건: 장르 또는 태그 중 하나라도 일치하면 포함) + BooleanBuilder genreOrTagCondition = buildGenreOrTagCondition(selectedGenres, selectedTags); + if (genreOrTagCondition.hasValue()) { + where.and(genreOrTagCondition); + } + + // 필수 필터로 먼저 조회 + List filteredGameIds = queryFactory.select(game.id) + .from(game) + .where(where) + .fetch(); + + if (filteredGameIds.isEmpty()) { + return Optional.empty(); + } + + // 선택적 필터를 하나씩 적용하며 점진적으로 필터링 + filteredGameIds = applyOptionalFilters(filteredGameIds, where, userPreferences); + + if (filteredGameIds.isEmpty()) { + return Optional.empty(); + } + + // 필터링 후 최신 출시 게임 1개 상세 조회 + List gameResults = queryFactory.select( + game.id, + game.gameName, + game.gameInitialPrice, + game.headerImage, + game.peakCcu, + game.releaseDate, + game.averagePlaytimeForever) + .from(game) + .where(game.id.in(filteredGameIds)) + .orderBy(game.releaseDate.desc()) + .limit(1) + .fetch(); + + if (gameResults.isEmpty()) { + return Optional.empty(); + } + + Tuple gameResult = gameResults.getFirst(); + Long gameId = gameResult.get(game.id); + if (gameId == null) { + return Optional.empty(); + } + + // 장르 및 태그 매핑 + Map> genreMap = fetchGenresByGameIds(List.of(gameId)); + Map> tagMap = fetchTagsByGameIds(List.of(gameId)); + + GameCriteria gameCriteria = GameCriteria.builder() + .id(gameId) + .gameName(gameResult.get(game.gameName)) + .gameInitialPrice(gameResult.get(game.gameInitialPrice)) + .headerImage(gameResult.get(game.headerImage)) + .peakCcu(gameResult.get(game.peakCcu)) + .releaseDate(gameResult.get(game.releaseDate)) + .averagePlaytimeForever(gameResult.get(game.averagePlaytimeForever)) + .genres(genreMap.getOrDefault(gameId, List.of())) + .tags(tagMap.getOrDefault(gameId, List.of())) + .build(); + + return Optional.of(gameCriteria); + } + + /** + * 기본 필터 (필수): 기본 조건 + 가격만 + */ + private BooleanBuilder buildBasicWhereClause(UserPreferences userPreferences) { + BooleanBuilder where = new BooleanBuilder(); + + // 기본 필터 조건 + where.and(game.averagePlaytimeForever.gt(0).or(game.averagePlaytime2weeks.gt(0))) + .and(game.positive.gt(0).or(game.negative.gt(0))) + .and(game.peakCcu.gt(500)); + + // 가격 필터 (필수) + if (userPreferences != null && userPreferences.getGamePrice() != null) { + where.and(game.gameFinalPrice.coalesce(0).loe(userPreferences.getGamePrice())); + } + + return where; + } + + /** + * 장르/태그 필터 생성 + */ + private BooleanBuilder buildGenreOrTagCondition(List selectedGenres, List selectedTags) { + BooleanBuilder genreOrTagCondition = new BooleanBuilder(); + + // 장르 필터 (EXISTS 서브쿼리) + if (selectedGenres != null && !selectedGenres.isEmpty()) { + BooleanExpression genreExists = queryFactory + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.genre)) + .and(attribute.name.in(selectedGenres)) + ) + .exists(); + genreOrTagCondition.or(genreExists); + } + + // 태그 필터 (EXISTS 서브쿼리) + if (selectedTags != null && !selectedTags.isEmpty()) { + BooleanExpression tagExists = queryFactory + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.tag)) + .and(attribute.name.in(selectedTags)) + ) + .exists(); + genreOrTagCondition.or(tagExists); + } + + return genreOrTagCondition; + } + + /** + * 선택적 필터를 하나씩 적용하며 점진적으로 필터링 + * 각 필터 적용 후 결과가 없으면 해당 필터를 제거하고 다음 필터로 진행 + */ + private List applyOptionalFilters(List currentGameIds, BooleanBuilder baseWhere, UserPreferences userPreferences) { + if (userPreferences == null || currentGameIds.isEmpty()) { + return currentGameIds; + } + + List filteredIds = new ArrayList<>(currentGameIds); + BooleanBuilder appliedFilters = new BooleanBuilder(); + + // 1. 멀티플레이어 필터 시도 + if (userPreferences.getMultiplayer() != null) { + BooleanExpression multiplayerFilter; + if (userPreferences.getMultiplayer() > 0) { + multiplayerFilter = game.peakCcu.goe(1000); + } else { + multiplayerFilter = game.peakCcu.lt(1000).or(game.peakCcu.isNull()); + } + + List testIds = queryFactory.select(game.id) + .from(game) + .where(baseWhere.and(appliedFilters).and(multiplayerFilter) + .and(game.id.in(filteredIds))) + .fetch(); + + if (!testIds.isEmpty()) { + filteredIds = testIds; + appliedFilters.and(multiplayerFilter); + } + } + + // 2. 최근 출시작 필터 시도 + if (userPreferences.getRecentRelease() != null && userPreferences.getRecentRelease() > 0) { + LocalDate oneYearAgo = LocalDate.now().minusYears(1); + BooleanExpression recentReleaseFilter = game.releaseDate.goe(oneYearAgo); + + List testIds = queryFactory.select(game.id) + .from(game) + .where(baseWhere.and(appliedFilters).and(recentReleaseFilter) + .and(game.id.in(filteredIds))) + .fetch(); + + if (!testIds.isEmpty()) { + filteredIds = testIds; + appliedFilters.and(recentReleaseFilter); + } + } + + // 3. 난이도 필터 시도 + List difficultyKeywords = getTagKeywordsForDifficulty(userPreferences); + if (!difficultyKeywords.isEmpty()) { + BooleanBuilder difficultyCondition = new BooleanBuilder(); + for (String keyword : difficultyKeywords) { + BooleanExpression keywordExists = queryFactory + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.tag)) + .and(attribute.name.toLowerCase().contains(keyword.toLowerCase())) + ) + .exists(); + difficultyCondition.or(keywordExists); + } + + List testIds = queryFactory.select(game.id) + .from(game) + .where(baseWhere.and(appliedFilters).and(difficultyCondition) + .and(game.id.in(filteredIds))) + .fetch(); + + if (!testIds.isEmpty()) { + filteredIds = testIds; + appliedFilters.and(difficultyCondition); + } + } else if (userPreferences.getDifficultyMedium() != null && userPreferences.getDifficultyMedium() > 0) { + // 중간 난이도: 쉬움/어려움 태그가 없는 게임 + BooleanExpression hasEasyTag = queryFactory + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.tag)) + .and(attribute.name.toLowerCase().contains("casual")) + ) + .exists(); + + BooleanExpression hasHardTag = queryFactory + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.tag)) + .and(attribute.name.toLowerCase().in("difficult", "unforgiving", "perma death")) + ) + .exists(); + + BooleanExpression mediumDifficultyFilter = hasEasyTag.not().and(hasHardTag.not()); + + List testIds = queryFactory.select(game.id) + .from(game) + .where(baseWhere.and(appliedFilters).and(mediumDifficultyFilter) + .and(game.id.in(filteredIds))) + .fetch(); + + if (!testIds.isEmpty()) { + filteredIds = testIds; + appliedFilters.and(mediumDifficultyFilter); + } + } + + // 4. 플레이타임 필터 시도 + BooleanExpression playtimeFilter = null; + if (userPreferences.getPlaytimeShort() != null && userPreferences.getPlaytimeShort() > 0) { + playtimeFilter = game.averagePlaytimeForever.loe(10); + } else if (userPreferences.getPlaytimeMedium() != null && userPreferences.getPlaytimeMedium() > 0) { + playtimeFilter = game.averagePlaytimeForever.gt(10).and(game.averagePlaytimeForever.loe(50)); + } else if (userPreferences.getPlaytimeLong() != null && userPreferences.getPlaytimeLong() > 0) { + playtimeFilter = game.averagePlaytimeForever.gt(50); + } + + if (playtimeFilter != null) { + List testIds = queryFactory.select(game.id) + .from(game) + .where(baseWhere.and(appliedFilters).and(playtimeFilter) + .and(game.id.in(filteredIds))) + .fetch(); + + if (!testIds.isEmpty()) { + filteredIds = testIds; + appliedFilters.and(playtimeFilter); + } + } + + // 5. 목표 필터 시도 + List goalKeywords = getTagKeywordsForGoal(userPreferences); + if (!goalKeywords.isEmpty()) { + BooleanBuilder goalCondition = new BooleanBuilder(); + for (String keyword : goalKeywords) { + BooleanExpression keywordExists = queryFactory + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.tag)) + .and(attribute.name.toLowerCase().contains(keyword.toLowerCase())) + ) + .exists(); + goalCondition.or(keywordExists); + } + + List testIds = queryFactory.select(game.id) + .from(game) + .where(baseWhere.and(appliedFilters).and(goalCondition) + .and(game.id.in(filteredIds))) + .fetch(); + + if (!testIds.isEmpty()) { + filteredIds = testIds; + // 마지막 필터이므로 appliedFilters에 추가할 필요 없음 + } + } + + return filteredIds; + } + + /** + * 난이도 필터 위한 키워드 + */ + private List getTagKeywordsForDifficulty(UserPreferences userPreferences) { + if (userPreferences == null) { + return List.of(); + } + if (userPreferences.getDifficultyEasy() != null && userPreferences.getDifficultyEasy() > 0) { + return List.of("casual"); + } else if (userPreferences.getDifficultyHard() != null && userPreferences.getDifficultyHard() > 0) { + return List.of("difficult", "unforgiving", "perma death"); + } + return List.of(); + } + + /** + * 목표 필터 위한 키워드 + */ + private List getTagKeywordsForGoal(UserPreferences userPreferences) { + if (userPreferences == null) { + return List.of(); + } + List keywords = new ArrayList<>(); + if (userPreferences.getGoalStory() != null && userPreferences.getGoalStory() > 0) { + keywords.addAll(List.of("story", "narrative", "campaign")); + } + if (userPreferences.getGoalCompetition() != null && userPreferences.getGoalCompetition() > 0) { + keywords.addAll(List.of("competitive", "pvp", "sports")); + } + if (userPreferences.getGoalBuilding() != null && userPreferences.getGoalBuilding() > 0) { + keywords.addAll(List.of("building", "crafting", "sandbox")); + } + return keywords; + } + + + /** + * 게임 ID 리스트로 태그 조회 + */ + private Map> fetchTagsByGameIds(List gameIds) { + if (gameIds == null || gameIds.isEmpty()) { + return Map.of(); + } + + List tagRows = queryFactory + .select(gameAttribute.id.gameId, attribute.name) + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.in(gameIds) + .and(attribute.type.eq(AttributeType.tag)) + ) + .fetch(); + + return tagRows.stream() + .collect(Collectors.groupingBy( + row -> row.get(gameAttribute.id.gameId), + Collectors.mapping( + row -> row.get(attribute.name), + Collectors.toList() + ) + )); + } + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/MediaRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/MediaRepository.java new file mode 100644 index 0000000..f04496b --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/MediaRepository.java @@ -0,0 +1,30 @@ +package com.gg.application.game.repository; + +import com.gg.application.game.entity.Media; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface MediaRepository extends JpaRepository { + + @Query("select m.url " + + "from Media m " + + "where m.game.id = :gameId " + + "and m.type = 'screenshot'") + Optional> findScreenshotsById(@Param("gameId") Long gameId); + + @Query("select m.url " + + "from Media m " + + "where m.game.id = :gameId " + + "and m.type = 'movie'") + Optional> findMoviesById(@Param("gameId") Long gameId); + + @Query("select m.type as type, m.url as url " + + "from Media m " + + "where m.game.id = :gameId " + + "and m.type in ('screenshot','movie')") + List findMediaUrlsByGameId(@Param("gameId") Long gameId); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/MediaTypeUrlProjection.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/MediaTypeUrlProjection.java new file mode 100644 index 0000000..a2c069a --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/repository/MediaTypeUrlProjection.java @@ -0,0 +1,8 @@ +package com.gg.application.game.repository; + +public interface MediaTypeUrlProjection { + String getType(); + String getUrl(); +} + + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/service/AttributeService.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/service/AttributeService.java new file mode 100644 index 0000000..5645d1e --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/service/AttributeService.java @@ -0,0 +1,35 @@ +package com.gg.application.game.service; + +import com.gg.application.game.dto.response.AttributeResponseDto; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.repository.AttributeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly=true) +@RequiredArgsConstructor +public class AttributeService { + + private final AttributeRepository attributeRepository; + private final GameCacheService gameCacheService; + + public List getAttributes(AttributeType type) { + // 캐시에서 먼저 조회 + List cached = gameCacheService.getCachedAttributes(type); + if (cached != null) { + return cached; + } + + // 캐시에 없으면 DB에서 조회 + List result = attributeRepository.findByType(type); + + // 캐시에 저장 + gameCacheService.setCachedAttributes(type, result); + + return result; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/service/GameCacheService.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/service/GameCacheService.java new file mode 100644 index 0000000..8aa2232 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/service/GameCacheService.java @@ -0,0 +1,432 @@ +package com.gg.application.game.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gg.application.game.dto.response.AttributeResponseDto; +import com.gg.application.game.dto.response.GameDetailResponseDto; +import com.gg.application.game.dto.response.GameMediaResponseDto; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.user.repository.UserRepository; +import com.github.benmanes.caffeine.cache.Cache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@Service +public class GameCacheService { + + private static final Logger log = LoggerFactory.getLogger(GameCacheService.class); + private static final String COUNT_CACHE_KEY_PREFIX = "game_count:"; + private static final String TAG_FILTER_CACHE_KEY_PREFIX = "user_tag_filter:"; + private static final String GAME_DETAIL_CACHE_KEY_PREFIX = "game:detail:"; + private static final String GAME_MEDIA_CACHE_KEY_PREFIX = "game:media:"; + private static final String ATTRIBUTES_CACHE_KEY_PREFIX = "attributes:"; + + private final Cache tagFilterCache; + private final Cache gameDetailCache; + private final Cache gameMediaCache; + private final Cache> attributesCache; + private final Cache gameCountCache; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Lazy + private final UserRepository userRepository; + + @Value("${cache.redis.tag-filter.ttl-hours:1}") + private long tagFilterTtlHours; + + @Value("${cache.redis.game-detail.ttl-hours:2}") + private long gameDetailTtlHours; + + @Value("${cache.redis.game-media.ttl-hours:2}") + private long gameMediaTtlHours; + + @Value("${cache.redis.attributes.ttl-hours:24}") + private long attributesTtlHours; + + @Value("${cache.redis.game-count.ttl-hours:1}") + private long gameCountTtlHours; + + public GameCacheService( + @Qualifier("tagFilterCache") Cache tagFilterCache, + @Qualifier("gameDetailCache") Cache gameDetailCache, + @Qualifier("gameMediaCache") Cache gameMediaCache, + @Qualifier("attributesCache") Cache> attributesCache, + @Qualifier("gameCountCache") Cache gameCountCache, + StringRedisTemplate redisTemplate, + ObjectMapper objectMapper, + @Lazy UserRepository userRepository + ) { + this.tagFilterCache = tagFilterCache; + this.gameDetailCache = gameDetailCache; + this.gameMediaCache = gameMediaCache; + this.attributesCache = attributesCache; + this.gameCountCache = gameCountCache; + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.userRepository = userRepository; + } + + private String getCountCacheKey(String filterHash) { + return COUNT_CACHE_KEY_PREFIX + filterHash; + } + + public Long getCachedCount(String filterHash) { + if (filterHash == null || filterHash.isEmpty()) return null; + + Long cached = gameCountCache.getIfPresent(filterHash); + if (cached != null) { + log.debug("게임 count 인메모리 캐시 히트: filterHash={}", filterHash); + return cached; + } + + String key = getCountCacheKey(filterHash); + String redisValue = redisTemplate.opsForValue().get(key); + + if (redisValue != null) { + try { + Long count = Long.parseLong(redisValue); + gameCountCache.put(filterHash, count); + log.debug("게임 count Redis 캐시 히트: filterHash={}", filterHash); + return count; + } catch (NumberFormatException e) { + log.warn("게임 count Redis 값 파싱 실패: filterHash={}, value={}", filterHash, redisValue, e); + redisTemplate.delete(key); + } + } + + return null; + } + + public void setCachedCount(String filterHash, Long count) { + if (filterHash == null || filterHash.isEmpty() || count == null) return; + + gameCountCache.put(filterHash, count); + + String key = getCountCacheKey(filterHash); + try { + redisTemplate.opsForValue().set(key, String.valueOf(count), gameCountTtlHours, TimeUnit.HOURS); + } catch (Exception e) { + log.error("게임 count Redis 캐시 저장 실패: filterHash={}, count={}", filterHash, count, e); + } + } + + @SuppressWarnings("deprecation") + public void invalidateAllCountCache() { + gameCountCache.invalidateAll(); + + try { + String pattern = COUNT_CACHE_KEY_PREFIX + "*"; + Set keys = redisTemplate.execute((org.springframework.data.redis.core.RedisCallback>) connection -> { + Set result = new java.util.HashSet<>(); + org.springframework.data.redis.core.Cursor cursor = connection.scan( + org.springframework.data.redis.core.ScanOptions.scanOptions() + .match(pattern) + .count(100) + .build()); + try { + while (cursor.hasNext()) { + byte[] keyBytes = cursor.next(); + result.add(new String(keyBytes, java.nio.charset.StandardCharsets.UTF_8)); + } + } finally { + try { + cursor.close(); + } catch (Exception e) { + log.debug("Cursor 닫기 실패", e); + } + } + return result; + }); + + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + log.debug("전체 count 캐시 무효화: {}건", keys.size()); + } + } catch (Exception e) { + log.error("전체 count 캐시 무효화 실패", e); + } + } + + @Transactional(readOnly = true) + public Boolean getTagFilterEnabled(Integer userId) { + if (userId == null) return true; + + Boolean cached = tagFilterCache.getIfPresent(userId); + if (cached != null) { + log.debug("tag_filter_enabled 인메모리 캐시 히트: userId={}", userId); + return cached; + } + + String key = TAG_FILTER_CACHE_KEY_PREFIX + userId; + String redisValue = redisTemplate.opsForValue().get(key); + + if (redisValue != null) { + try { + Boolean value = Boolean.parseBoolean(redisValue); + tagFilterCache.put(userId, value); + log.debug("tag_filter_enabled Redis 캐시 히트: userId={}", userId); + return value; + } catch (Exception e) { + log.warn("tag_filter_enabled Redis 값 파싱 실패: userId={}, value={}", userId, redisValue, e); + redisTemplate.delete(key); + } + } + + Boolean enabled = userRepository.findTagFilterEnabledByUserId(userId).orElse(true); + + tagFilterCache.put(userId, enabled); + setTagFilterEnabled(userId, enabled); + + log.debug("tag_filter_enabled DB 조회: userId={}, enabled={}", userId, enabled); + return enabled; + } + + public void setTagFilterEnabled(Integer userId, Boolean enabled) { + if (userId == null) return; + + tagFilterCache.put(userId, enabled); + + String key = TAG_FILTER_CACHE_KEY_PREFIX + userId; + String value = enabled != null ? String.valueOf(enabled) : "true"; + try { + redisTemplate.opsForValue().set(key, value, tagFilterTtlHours, TimeUnit.HOURS); + } catch (Exception e) { + log.error("tag_filter_enabled Redis 캐시 저장 실패: userId={}, enabled={}", userId, enabled, e); + } + } + + public void invalidateTagFilterEnabled(Integer userId) { + if (userId == null) return; + + tagFilterCache.invalidate(userId); + + String key = TAG_FILTER_CACHE_KEY_PREFIX + userId; + try { + redisTemplate.delete(key); + log.debug("tag_filter_enabled 캐시 무효화: userId={}", userId); + } catch (Exception e) { + log.error("tag_filter_enabled Redis 캐시 무효화 실패: userId={}", userId, e); + } + } + + public GameDetailResponseDto getCachedGameDetail(Long gameId) { + if (gameId == null) return null; + + GameDetailResponseDto cached = gameDetailCache.getIfPresent(gameId); + if (cached != null) { + log.debug("게임 상세 정보 인메모리 캐시 히트: gameId={}", gameId); + return cached; + } + + String key = GAME_DETAIL_CACHE_KEY_PREFIX + gameId; + try { + String redisValue = redisTemplate.opsForValue().get(key); + if (redisValue != null) { + GameDetailResponseDto detail = objectMapper.readValue(redisValue, GameDetailResponseDto.class); + gameDetailCache.put(gameId, detail); + log.debug("게임 상세 정보 Redis 캐시 히트: gameId={}", gameId); + return detail; + } + } catch (Exception e) { + log.warn("게임 상세 정보 Redis 캐시 조회 실패: gameId={}", gameId, e); + redisTemplate.delete(key); + } + + return null; + } + + public void setCachedGameDetail(Long gameId, GameDetailResponseDto detail) { + if (gameId == null || detail == null) return; + + gameDetailCache.put(gameId, detail); + + String key = GAME_DETAIL_CACHE_KEY_PREFIX + gameId; + try { + String json = objectMapper.writeValueAsString(detail); + redisTemplate.opsForValue().set(key, json, gameDetailTtlHours, TimeUnit.HOURS); + } catch (Exception e) { + log.error("게임 상세 정보 Redis 캐시 저장 실패: gameId={}", gameId, e); + } + } + + public void invalidateGameDetail(Long gameId) { + if (gameId == null) return; + + gameDetailCache.invalidate(gameId); + + String key = GAME_DETAIL_CACHE_KEY_PREFIX + gameId; + try { + redisTemplate.delete(key); + log.debug("게임 상세 정보 캐시 무효화: gameId={}", gameId); + } catch (Exception e) { + log.error("게임 상세 정보 Redis 캐시 무효화 실패: gameId={}", gameId, e); + } + } + + public GameMediaResponseDto getCachedGameMedia(Long gameId) { + if (gameId == null) return null; + + GameMediaResponseDto cached = gameMediaCache.getIfPresent(gameId); + if (cached != null) { + log.debug("게임 미디어 정보 인메모리 캐시 히트: gameId={}", gameId); + return cached; + } + + String key = GAME_MEDIA_CACHE_KEY_PREFIX + gameId; + try { + String redisValue = redisTemplate.opsForValue().get(key); + if (redisValue != null) { + GameMediaResponseDto media = objectMapper.readValue(redisValue, GameMediaResponseDto.class); + gameMediaCache.put(gameId, media); + log.debug("게임 미디어 정보 Redis 캐시 히트: gameId={}", gameId); + return media; + } + } catch (Exception e) { + log.warn("게임 미디어 정보 Redis 캐시 조회 실패: gameId={}", gameId, e); + redisTemplate.delete(key); + } + + return null; + } + + public void setCachedGameMedia(Long gameId, GameMediaResponseDto media) { + if (gameId == null || media == null) return; + + gameMediaCache.put(gameId, media); + + String key = GAME_MEDIA_CACHE_KEY_PREFIX + gameId; + try { + String json = objectMapper.writeValueAsString(media); + redisTemplate.opsForValue().set(key, json, gameMediaTtlHours, TimeUnit.HOURS); + } catch (Exception e) { + log.error("게임 미디어 정보 Redis 캐시 저장 실패: gameId={}", gameId, e); + } + } + + public void invalidateGameMedia(Long gameId) { + if (gameId == null) return; + + gameMediaCache.invalidate(gameId); + + String key = GAME_MEDIA_CACHE_KEY_PREFIX + gameId; + try { + redisTemplate.delete(key); + log.debug("게임 미디어 정보 캐시 무효화: gameId={}", gameId); + } catch (Exception e) { + log.error("게임 미디어 정보 Redis 캐시 무효화 실패: gameId={}", gameId, e); + } + } + + public List getCachedAttributes(AttributeType type) { + if (type == null) return null; + + List cached = attributesCache.getIfPresent(type); + if (cached != null) { + log.debug("속성 목록 인메모리 캐시 히트: type={}", type); + return cached; + } + + String key = ATTRIBUTES_CACHE_KEY_PREFIX + type.name(); + try { + String redisValue = redisTemplate.opsForValue().get(key); + if (redisValue != null) { + List attributes = objectMapper.readValue( + redisValue, + new TypeReference>() {} + ); + attributesCache.put(type, attributes); + log.debug("속성 목록 Redis 캐시 히트: type={}", type); + return attributes; + } + } catch (Exception e) { + log.warn("속성 목록 Redis 캐시 조회 실패: type={}", type, e); + redisTemplate.delete(key); + } + + return null; + } + + public void setCachedAttributes(AttributeType type, List attributes) { + if (type == null || attributes == null) return; + + attributesCache.put(type, attributes); + + String key = ATTRIBUTES_CACHE_KEY_PREFIX + type.name(); + try { + String json = objectMapper.writeValueAsString(attributes); + redisTemplate.opsForValue().set(key, json, attributesTtlHours, TimeUnit.HOURS); + } catch (Exception e) { + log.error("속성 목록 Redis 캐시 저장 실패: type={}", type, e); + } + } + + @SuppressWarnings("deprecation") + public void invalidateAttributes(AttributeType type) { + if (type == null) { + attributesCache.invalidateAll(); + + try { + String pattern = ATTRIBUTES_CACHE_KEY_PREFIX + "*"; + Set keys = redisTemplate.execute((org.springframework.data.redis.core.RedisCallback>) connection -> { + Set result = new java.util.HashSet<>(); + org.springframework.data.redis.core.Cursor cursor = connection.scan( + org.springframework.data.redis.core.ScanOptions.scanOptions() + .match(pattern) + .count(100) + .build()); + try { + while (cursor.hasNext()) { + byte[] keyBytes = cursor.next(); + result.add(new String(keyBytes, java.nio.charset.StandardCharsets.UTF_8)); + } + } finally { + try { + cursor.close(); + } catch (Exception e) { + log.debug("Cursor 닫기 실패", e); + } + } + return result; + }); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + log.debug("모든 속성 목록 캐시 무효화: {}건", keys.size()); + } + } catch (Exception e) { + log.error("속성 목록 Redis 캐시 무효화 실패", e); + } + } else { + attributesCache.invalidate(type); + + String key = ATTRIBUTES_CACHE_KEY_PREFIX + type.name(); + try { + redisTemplate.delete(key); + log.debug("속성 목록 캐시 무효화: type={}", type); + } catch (Exception e) { + log.error("속성 목록 Redis 캐시 무효화 실패: type={}", type, e); + } + } + } + + public void logCacheStats() { + log.info("=== 캐시 통계 ==="); + log.info("TagFilter Cache: {}", tagFilterCache.stats()); + log.info("GameDetail Cache: {}", gameDetailCache.stats()); + log.info("GameMedia Cache: {}", gameMediaCache.stats()); + log.info("Attributes Cache: {}", attributesCache.stats()); + log.info("GameCount Cache: {}", gameCountCache.stats()); + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/game/service/GameService.java b/gg_web/gg_backend/src/main/java/com/gg/application/game/service/GameService.java new file mode 100644 index 0000000..d72b89c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/game/service/GameService.java @@ -0,0 +1,387 @@ +package com.gg.application.game.service; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.dto.response.GameDetailResponseDto; +import com.gg.application.game.dto.response.GameIdNamePair; +import com.gg.application.game.dto.response.GameListResponseDto; +import com.gg.application.game.dto.response.GameMediaResponseDto; +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.game.dto.response.PlayerRankingResponseDto; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.entity.Game; +import com.gg.application.game.repository.GameRepository; +import com.gg.application.game.repository.MediaRepository; +import com.gg.application.game.repository.MediaTypeUrlProjection; +import com.gg.common.exception.NotFoundException; +import com.gg.config.security.CustomUserDetails; +import com.querydsl.core.Tuple; +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static com.gg.application.game.entity.QAttribute.attribute; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GameService { + + private static final Logger log = LoggerFactory.getLogger(GameService.class); + + private final GameRepository gameRepository; + private final MediaRepository mediaRepository; + private final StringRedisTemplate redisTemplate; + private final GameCacheService gameCacheService; + private final SortResolver sortResolver = new SortResolver(); + + // 비로그인(guest) ⇒ 필터 적용(true) + // 로그인 ⇒ users.tag_filter_enabled == true ⇒ 필터 적용(true) + private boolean shouldApplyTagFilter(@Nullable CustomUserDetails user) { + if (user == null) return true; // 비로그인 + + // 로그인 → Redis 캐시에서 tag_filter_enabled 조회 + Boolean enabled = gameCacheService.getTagFilterEnabled(user.getUserId()); + // DB 값이 true이면 필터 적용, null 또는 false면 미적용 + return enabled != null ? enabled : true; + } + + public GameDetailResponseDto getGameDetail(long gameId) { + // 캐시에서 먼저 조회 + GameDetailResponseDto cached = gameCacheService.getCachedGameDetail(gameId); + if (cached != null) { + return cached; + } + + // 캐시에 없으면 DB에서 조회 + GameDetailResponseDto dto = gameRepository.findGameBase(gameId) + .orElseThrow(() -> new NotFoundException("게임을 찾을 수 없습니다.")); + + List attrs = gameRepository.findAttributes(gameId); + Map> byType = groupByType(attrs); + + List mediaRows = mediaRepository.findMediaUrlsByGameId(gameId); + List screenshots = new ArrayList<>(); + List movies = new ArrayList<>(); + for (MediaTypeUrlProjection row : mediaRows) { + String type = row.getType(); + if (type == null) continue; + if ("screenshot".equalsIgnoreCase(type)) { + screenshots.add(row.getUrl()); + } else if ("movie".equalsIgnoreCase(type)) { + movies.add(row.getUrl()); + } + } + if (screenshots.isEmpty()) screenshots = null; + if (movies.isEmpty()) movies = null; + + // DTO 새로 생성 (모든 필드 + attributes 채워서) + GameDetailResponseDto result = new GameDetailResponseDto( + dto.getGameId(), dto.getGameName(), dto.getReleaseDate(), + dto.getGameInitialPrice(), dto.getGameDiscount(), dto.getGameFinalPrice(), + dto.getGameDescription(), dto.getPositive(), dto.getNegative(), + dto.getIsWindow(), dto.getIsMac(), dto.getIsLinux(), + dto.getAveragePlaytime2weeks(), dto.getAveragePlaytimeForever(), + dto.getPeakCcu(), dto.getRating(), dto.getLikeCount(), + byType.getOrDefault("developer", List.of()), + byType.getOrDefault("publisher", List.of()), + byType.getOrDefault("genre", List.of()), + byType.getOrDefault("tag", List.of()), + byType.getOrDefault("category", List.of()), + byType.getOrDefault("language", List.of()), + dto.getHeaderImage(), + screenshots, movies + ); + + // 캐시에 저장 + gameCacheService.setCachedGameDetail(gameId, result); + + return result; + } + + public PageResponse getGames(GameListRequestDto req, int page, int size, String sort, String order, @Nullable CustomUserDetails user) { + // 검색어 길이 검증 + if (req != null && req.getQ() != null && req.getQ().length() > 50) { + throw new IllegalArgumentException("검색어는 50자 이하여야 합니다."); + } + + int p = page < 0 ? 0 : page; + int s = size < 1 ? 20 : Math.min(size, 100); + + Sort resolvedSort = sortResolver.resolve(sort, order); + Pageable pageable = PageRequest.of(p, s, resolvedSort); + + boolean apply = shouldApplyTagFilter(user); + + Page result = gameRepository.searchGame(req, pageable, apply); + String sortKey = sortResolver.normalizeKey(sort); + String sortOrder = sortResolver.normalizeOrder(order); + + return PageResponse.of(result, sortKey, sortOrder); + } + + public GameMediaResponseDto getGameMedia(long gameId) { + // 캐시에서 먼저 조회 + GameMediaResponseDto cached = gameCacheService.getCachedGameMedia(gameId); + if (cached != null) { + return cached; + } + + // 캐시에 없으면 DB에서 조회 + String headerImage = gameRepository.findHeaderImageById(gameId).orElse(null); + List screenshots = mediaRepository.findScreenshotsById(gameId).orElse(null); + List movies = mediaRepository.findMoviesById(gameId).orElse(null); + + GameMediaResponseDto result = GameMediaResponseDto.builder() + .gameId(gameId) + .headerImage(headerImage) + .screenshots(screenshots) + .movies(movies) + .build(); + + // 캐시에 저장 + gameCacheService.setCachedGameMedia(gameId, result); + + return result; + } + + public PageResponse getPlayerRankings(String group, int page, int size) { + int p = page < 0 ? 0 : page; + int s = size < 1 ? 20 : Math.min(size, 100); + + String key = "player_rankings"; + long groupLimit = resolveGroupLimit(group); + log.info("Redis 랭킹 조회 시작: key={}, group={}, page={}, size={}", key, group, p, s); + + // Redis Sorted Set에서 전체 랭킹 조회 (group 파라미터로 필터링) + Long totalElements = 0L; + Set> gameIdTuples; + try { + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + // 전체 개수 조회 + totalElements = zSetOps.zCard(key); + if (totalElements == null || totalElements == 0) { + log.warn("Redis에서 데이터를 찾을 수 없습니다: key={}. Redis에 키가 존재하는지 확인하세요.", key); + return createEmptyPageResponse(p, s); + } + + // group 파라미터에 따라 조회 범위 제한 (top100/top500/top1000/all) + if (groupLimit > 0) { + totalElements = Math.min(totalElements, groupLimit); + } + + if (totalElements == 0) { + return createEmptyPageResponse(p, s); + } + + // 페이지네이션 범위 계산 + long start = (long) p * s; + if (start >= totalElements) { + return createEmptyPageResponse(p, s); + } + + long end = start + s - 1; + if (groupLimit > 0) { + end = Math.min(end, groupLimit - 1); + } + + if (end < start) { + return createEmptyPageResponse(p, s); + } + + // Redis Sorted Set에서 페이지네이션 범위 조회 (score = player_count 포함) + gameIdTuples = zSetOps.reverseRangeWithScores(key, start, end); + + log.info("Redis 조회 결과: key={}, totalElements={}, gameIds size={}", key, totalElements, gameIdTuples != null ? gameIdTuples.size() : 0); + + if (gameIdTuples == null || gameIdTuples.isEmpty()) { + return createEmptyPageResponse(p, s); + } + } catch (Exception e) { + log.error("Redis 조회 중 오류 발생: key={}, error={}", key, e.getMessage(), e); + return createEmptyPageResponse(p, s); + } + + // 게임 ID와 player_count 매핑 생성 (Sorted Set의 score에서 직접 가져옴) + List gameIds = new ArrayList<>(); + Map gameIdToPlayerCount = new HashMap<>(); + for (ZSetOperations.TypedTuple tuple : gameIdTuples) { + try { + Long gameId = Long.parseLong(tuple.getValue()); + Integer playerCount = tuple.getScore() != null ? tuple.getScore().intValue() : 0; + gameIds.add(gameId); + gameIdToPlayerCount.put(gameId, playerCount); + } catch (NumberFormatException e) { + log.warn("게임 ID 파싱 실패: gameIdString={}", tuple.getValue()); + } + } + + // DB에서 게임 정보 일괄 조회 + List games = gameRepository.findAllById(gameIds); + Map gameMap = new HashMap<>(); + for (Game game : games) { + gameMap.put(game.getId(), game); + } + + // genres 일괄 조회 + List genrePairs = gameRepository.findAttributeNamebyGameIdAndType(gameIds, AttributeType.genre); + Map> gameIdToGenres = new HashMap<>(); + for (GameIdNamePair pair : genrePairs) { + gameIdToGenres.computeIfAbsent(pair.getGameId(), k -> new ArrayList<>()).add(pair.getName()); + } + + // DTO 생성 + List rankings = new ArrayList<>(); + int rank = (int) ((long) p * s) + 1; // 페이지네이션에 맞는 rank 계산 + for (Long gameId : gameIds) { + Game game = gameMap.get(gameId); + if (game == null) { + continue; // 게임이 DB에 없으면 스킵 + } + + rankings.add(PlayerRankingResponseDto.builder() + .gameId(gameId) + .playerCount(gameIdToPlayerCount.get(gameId)) + .rank(rank++) + .gameName(game.getGameName()) + .gameInitialPrice(game.getGameInitialPrice()) + .gameDiscount(game.getGameDiscount()) + .gameFinalPrice(game.getGameFinalPrice()) + .rating(game.getAvgRating() != null ? game.getAvgRating() : 0.0f) + .headerImage(game.getHeaderImage()) + .genres(gameIdToGenres.getOrDefault(gameId, List.of())) + .build()); + } + + // Page 객체 생성 + Pageable pageable = PageRequest.of(p, s); + Page resultPage = new PageImpl<>(rankings, pageable, totalElements); + + // PageResponse로 변환 (sort와 order는 랭킹이므로 고정) + return PageResponse.of(resultPage, "playerCount", "desc"); + } + + @Transactional(readOnly = true) + public Float getGameRating(Long gameId) { + if (gameId == null) return null; + Float rating = gameRepository.findAvgRatingByGameId(gameId); + return rating != null ? rating : 0.0f; + } + + @Transactional(readOnly = true) + public Map getGameRatings(List gameIds) { + Map result = new HashMap<>(); + if (gameIds == null || gameIds.isEmpty()) { + return result; + } + + List ratings = gameRepository.findAvgRatingsByGameIds(gameIds); + for (Object[] row : ratings) { + Long id = ((Number) row[0]).longValue(); + Float rating = row[1] != null ? ((Number) row[1]).floatValue() : 0.0f; + result.put(id, rating); + } + + for (Long gameId : gameIds) { + result.putIfAbsent(gameId, 0.0f); + } + + return result; + } + + @Transactional + public void refreshGameRating(Long gameId) { + if (gameId == null) return; + Float rating = gameRepository.calculateAvgRatingFromReviews(gameId); + Float normalized = rating != null ? rating : 0.0f; + gameRepository.updateAvgRating(gameId, normalized); + log.debug("avg_rating 갱신: gameId={}, rating={}", gameId, normalized); + } + + private PageResponse createEmptyPageResponse(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + return PageResponse.of(emptyPage, "playerCount", "desc"); + } + + private Map> groupByType(List rows) { + Map> map = new HashMap<>(); + for (Tuple t : rows) { + AttributeType typeEnum = t.get(attribute.type); // Enum 꺼내기 + String type = typeEnum.name(); + String name = t.get(attribute.name); + map.computeIfAbsent(type, k -> new ArrayList<>()).add(name); + } + return map; + } + + private long resolveGroupLimit(String group) { + if (group == null) { + return -1L; + } + return switch (group.toLowerCase(Locale.ROOT)) { + case "top100" -> 100L; + case "top500" -> 500L; + case "top1000" -> 1000L; + default -> -1L; + }; + } + + static final class SortResolver { + + Sort resolve(String sortKey, String order) { + String normalizedKey = normalizeKey(sortKey); + Sort.Direction dir = parseDirection(order); + + switch (normalizedKey.toLowerCase()) { + case "price": + case "gamefinalprice": + return Sort.by(dir, "gameFinalPrice"); + case "releasedate": + return Sort.by(dir, "releaseDate"); + case "like": + case "likecount": + return Sort.by(dir, "likeCount"); + case "rating": + return Sort.by(dir, "rating"); + case "averageplaytimeforever": + return Sort.by(dir, "averagePlaytimeForever"); + default: + // 기본 정렬: 출시일 DESC + return Sort.by(Sort.Direction.DESC, "releaseDate"); + } + } + + String normalizeKey(String key) { + if (key == null || key.isBlank()) return "releaseDate"; + return key; + } + + String normalizeOrder(String order) { + if (order == null || order.isBlank()) return "desc"; + return order.toLowerCase(); + } + + Sort.Direction parseDirection(String order) { + String normalized = normalizeOrder(order); + return "asc".equals(normalized) ? Sort.Direction.ASC : Sort.Direction.DESC; + } + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/controller/ReviewController.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/controller/ReviewController.java new file mode 100644 index 0000000..64da5ff --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/controller/ReviewController.java @@ -0,0 +1,105 @@ +package com.gg.application.review.controller; + +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.review.dto.request.ReviewListRequestDto; +import com.gg.application.review.dto.request.ReviewRequestDto; +import com.gg.application.review.dto.response.MyReviewListItemDto; +import com.gg.application.review.dto.response.MyReviewResponseDto; +import com.gg.application.review.dto.response.ReviewListResponseDto; +import com.gg.application.review.service.ReviewService; +import com.gg.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/review") +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping("/{gameId}") + public ResponseEntity create( + @PathVariable Long gameId, + @AuthenticationPrincipal CustomUserDetails user, + @RequestBody ReviewRequestDto req + ) { + if (user == null) return ResponseEntity.status(401).build(); + reviewService.createReview(gameId, user.getUserId(), req); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/{reviewId}") + public ResponseEntity delete( + @PathVariable Integer reviewId, + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + reviewService.deleteMyReview(reviewId, user.getUserId()); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/{gameId}/{reviewId}") + public ResponseEntity updateMyReview( + @PathVariable Integer reviewId, + @PathVariable Long gameId, + @RequestBody ReviewRequestDto req, + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + reviewService.updateMyReview(reviewId, gameId, user.getUserId(), req); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{gameId}/list") + @Operation(summary = "게임별 리뷰 목록", description = "리뷰 목록 조회(필터/정렬) — 기본 정렬: helpful desc") + public ResponseEntity> getGameReviews( + @PathVariable Long gameId, + @ParameterObject @ModelAttribute ReviewListRequestDto req, + @Parameter(description = "페이지(0-base)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "정렬 키(rating | playtime | updatedAt | helpful)") + @RequestParam(defaultValue = "helpful") String sort, + @Parameter(description = "정렬 방향(asc|desc)") + @RequestParam(defaultValue = "desc") String order, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails user + ) { + Integer userId = (user != null) ? user.getUserId() : null; + PageResponse body = reviewService.getGameReviews(gameId, req, page, size, sort, order, userId); + return ResponseEntity.ok(body); + } + + @GetMapping("/{gameId}/me") + @Operation(summary = "해당 게임에 대한 내 리뷰 조회", description = "로그인 사용자의 단건 리뷰 반환") + public ResponseEntity getMyReview( + @PathVariable Long gameId, + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) { + return ResponseEntity.status(401).build(); + } + + MyReviewResponseDto dto = reviewService.getMyReview(gameId, user.getUserId()); + return ResponseEntity.ok(dto); + } + + @GetMapping("/me") + @Operation(summary = "내가 쓴 리뷰 목록", description = "로그인한 사용자의 리뷰 목록을 최신순(createdAt DESC)으로 반환합니다.") + public ResponseEntity> getMyReviews( + @Parameter(description = "페이지(0-base)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + + PageResponse body = reviewService.getMyReviewList(user.getUserId(), page, size); + return ResponseEntity.ok(body); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/controller/ReviewHelpfulController.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/controller/ReviewHelpfulController.java new file mode 100644 index 0000000..5769417 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/controller/ReviewHelpfulController.java @@ -0,0 +1,53 @@ +package com.gg.application.review.controller; + +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.review.dto.response.MyLikedReviewResponseDto; +import com.gg.application.review.service.ReviewHelpfulService; +import com.gg.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/review/help") +@RequiredArgsConstructor +public class ReviewHelpfulController { + + private final ReviewHelpfulService reviewHelpfulService; + + @PostMapping("/{reviewId}") + public ResponseEntity addHelp( + @PathVariable Integer reviewId, + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + reviewHelpfulService.addHelpful(reviewId, user.getUserId()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{reviewId}") + public ResponseEntity deleteHelp( + @PathVariable Integer reviewId, + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + reviewHelpfulService.deleteHelpful(reviewId, user.getUserId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/me") + @Operation(summary = "내가 도움돼요 누른 리뷰 목록", description = "로그인한 사용자가 helpful 누른 리뷰를 페이징해서 반환합니다.") + public ResponseEntity> getMyHelpfulReviews( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + + PageResponse body = + reviewHelpfulService.getMyHelpfulReviews(user.getUserId(), page, size); + return ResponseEntity.ok(body); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/request/ReviewListRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/request/ReviewListRequestDto.java new file mode 100644 index 0000000..68c6f6e --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/request/ReviewListRequestDto.java @@ -0,0 +1,28 @@ +package com.gg.application.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@Getter +@AllArgsConstructor +@Schema(name = "ReviewListRequest", description = "리뷰 목록 조회 검색/필터/정렬 파라미터") +public class ReviewListRequestDto { + + @Schema(description = "별점 하한 (이상 필터)", example = "3.5") + private Float minRating; + + @Schema(description = "플레이타임 하한 (시간 단위, 이상 필터)", example = "1.5") + private Float minPlaytime; + + @Schema(description = "작성/수정일 기간 시작", type = "string", format = "date", example = "2025-10-01") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate start; + + @Schema(description = "작성/수정일 기간 종료", type = "string", format = "date", example = "2025-11-21") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate end; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/request/ReviewRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/request/ReviewRequestDto.java new file mode 100644 index 0000000..cb20956 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/request/ReviewRequestDto.java @@ -0,0 +1,11 @@ +package com.gg.application.review.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class ReviewRequestDto { + @Size(max = 100, message = "리뷰 내용은 100자 이하여야 합니다.") + private String content; + private Float rating; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyLikedReviewResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyLikedReviewResponseDto.java new file mode 100644 index 0000000..47ef029 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyLikedReviewResponseDto.java @@ -0,0 +1,23 @@ +package com.gg.application.review.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MyLikedReviewResponseDto { + private Integer reviewId; + private Long gameId; + private String gameName; + private String authorName; + private Float playtime; + private Float rating; + private String content; + private LocalDateTime createdAt; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyReviewListItemDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyReviewListItemDto.java new file mode 100644 index 0000000..f761c16 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyReviewListItemDto.java @@ -0,0 +1,23 @@ +package com.gg.application.review.dto.response; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MyReviewListItemDto { + private Integer reviewId; + private Long gameId; + private String gameName; + private Float rating; + private Float playtime; + private String content; + private Long helpfulCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyReviewResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyReviewResponseDto.java new file mode 100644 index 0000000..d137f68 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/MyReviewResponseDto.java @@ -0,0 +1,23 @@ +package com.gg.application.review.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MyReviewResponseDto { + private Integer reviewId; + private String authorName; + private Float rating; + private Float playtime; + private String content; + private Long helpfulCount; // 도움돼요 수 + private Boolean myHelpful; // 도움돼요 눌렀는지 + private LocalDateTime createdAt; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/ReviewListResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/ReviewListResponseDto.java new file mode 100644 index 0000000..5b51bc9 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/dto/response/ReviewListResponseDto.java @@ -0,0 +1,25 @@ +package com.gg.application.review.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReviewListResponseDto { + private Integer reviewId; + private Integer authorId; + private String authorName; + private Long gameId; + private String content; + private Float rating; + private Float playtime; // 평가 당시 플레이타임 + private Long helpfulCount; // 도움돼요 수 + private Boolean myHelpful; // 내가 도움돼요 눌렀는지 + private LocalDateTime createdAt; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/Review.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/Review.java new file mode 100644 index 0000000..738e151 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/Review.java @@ -0,0 +1,72 @@ +package com.gg.application.review.entity; + +import com.gg.application.game.entity.Game; +import com.gg.application.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "reviews", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "game_id"}) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@DynamicUpdate +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "review_id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "game_id", nullable = false) + private Game game; + + @Column(name = "content", nullable = false) + private String content; + + @Column(name = "rating", nullable = false) + private Float rating; + + @Column(name = "playtime", nullable = false) + private Float playtime; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public void update(String newContent, Float newRating, Float playtime) { + if (newContent != null) this.content = newContent; + if (newRating != null) this.rating = newRating; + if (playtime != null) this.playtime = playtime; + } + + public static Review of(User user, Game game, String content, Float rating, Float playtime) { + Review r = new Review(); + r.user = user; + r.game = game; + r.content = content; + r.rating = rating; + r.playtime = playtime; + return r; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/ReviewHelpful.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/ReviewHelpful.java new file mode 100644 index 0000000..ad664c7 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/ReviewHelpful.java @@ -0,0 +1,27 @@ +package com.gg.application.review.entity; + +import com.gg.application.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "review_helpfuls") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReviewHelpful { + + @EmbeddedId + private ReviewHelpfulId id; + + @MapsId("reviewId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/ReviewHelpfulId.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/ReviewHelpfulId.java new file mode 100644 index 0000000..f8e2ab5 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/entity/ReviewHelpfulId.java @@ -0,0 +1,35 @@ +package com.gg.application.review.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.Objects; + +@Embeddable +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReviewHelpfulId implements Serializable { + + @Column(name = "review_id") + private Integer reviewId; + + @Column(name = "user_id") + private Integer userId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ReviewHelpfulId that)) return false; + return Objects.equals(reviewId, that.reviewId) + && Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hash(reviewId, userId); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepository.java new file mode 100644 index 0000000..33a9482 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepository.java @@ -0,0 +1,64 @@ +package com.gg.application.review.repository; + +import com.gg.application.review.entity.ReviewHelpful; +import com.gg.application.review.entity.ReviewHelpfulId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +public interface ReviewHelpfulRepository extends JpaRepository, ReviewHelpfulRepositoryCustom { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + INSERT INTO review_helpfuls (review_id, user_id) + values(:reviewId, :userId) + ON CONFLICT DO NOTHING + """, nativeQuery = true) + int insertIfAbsent(Integer reviewId, Integer userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from ReviewHelpful rh where rh.review.id = :reviewId") + void deleteAllByReviewId(Integer reviewId); + + @Query(""" + select rh.review.id + from ReviewHelpful rh + where rh.user.userId = :userId + and rh.review.id in :reviewIds + """) + Set findMyHelpfulIds(@Param("userId") Integer userId, + @Param("reviewIds") Collection reviewIds); + + Long countByReview_Id(Integer reviewId); + + Boolean existsByReview_IdAndUser_UserId(Integer reviewId, Integer userId); + + @Query(""" + SELECT rh.review.id AS reviewId, COUNT(rh) AS cnt + FROM ReviewHelpful rh + WHERE rh.review.id IN :reviewIds + GROUP BY rh.review.id + """) + List countByReviewIdsGroupBy(@Param("reviewIds") Collection reviewIds); + + interface CountProjection { + Integer getReviewId(); + long getCnt(); + } + + Page findByUser_UserId(Integer userId, Pageable pageable); + + @Query(""" + select count(rh) + from ReviewHelpful rh + where rh.review.user.userId = :userId + """) + long countAllHelpfulReceivedByUser(@Param("userId") Integer userId); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepositoryCustom.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepositoryCustom.java new file mode 100644 index 0000000..e2982e9 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.gg.application.review.repository; + +import com.gg.application.review.entity.ReviewHelpful; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + + +public interface ReviewHelpfulRepositoryCustom { + Page findMyHelpfulReviewsFiltered(Integer userId, Pageable pageable, boolean applyFilter); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepositoryImpl.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepositoryImpl.java new file mode 100644 index 0000000..0f1f0c6 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewHelpfulRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.gg.application.review.repository; + +import com.gg.application.review.entity.QReview; +import com.gg.application.review.entity.QReviewHelpful; +import com.gg.application.review.entity.ReviewHelpful; +import com.gg.common.query.GameFilterPredicates; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ReviewHelpfulRepositoryImpl implements ReviewHelpfulRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final GameFilterPredicates gameFilterPredicates; + + @Override + public Page findMyHelpfulReviewsFiltered(Integer userId, Pageable pageable, boolean applyFilter) { + QReviewHelpful rh = QReviewHelpful.reviewHelpful; + QReview r = QReview.review; + + // where: 내가 누른 helpful + 금지 게임 제외 + BooleanBuilder where = new BooleanBuilder() + .and(rh.user.userId.eq(userId)) + .and(gameFilterPredicates.excludeGamesHavingAllThreeTagsOn(rh.review.game.id, applyFilter)); + + // 정렬 기본: id desc (기존과 동일) + Sort sort = pageable.getSort().isUnsorted() + ? Sort.by(Sort.Direction.DESC, "id") + : pageable.getSort(); + + // 페이지 데이터 + List rows = queryFactory + .selectFrom(rh) + .join(rh.review, r).fetchJoin() // 리뷰 즉시 로딩 + .where(where) + .orderBy(sort.stream().map(order -> { + boolean asc = order.isAscending(); + String prop = order.getProperty().toLowerCase(); + return switch (prop) { + case "id" -> asc ? rh.id.reviewId.asc() : rh.id.reviewId.desc(); + default -> asc ? rh.id.reviewId.asc() : rh.id.reviewId.desc(); + }; + }).toArray(com.querydsl.core.types.OrderSpecifier[]::new)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // total + Long total = queryFactory + .select(rh.count()) + .from(rh) + .join(rh.review, r) + .where(where) + .fetchOne(); + + return new PageImpl<>(rows, pageable, total == null ? 0L : total); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepository.java new file mode 100644 index 0000000..573c416 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepository.java @@ -0,0 +1,21 @@ +package com.gg.application.review.repository; + +import com.gg.application.review.entity.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ReviewRepository extends JpaRepository, ReviewRepositoryCustom { + + boolean existsByUser_UserIdAndGame_Id(Integer userId, Long gameId); + + Optional findByIdAndUser_UserId(Integer reviewId, Integer userId); + + Optional findByUser_UserIdAndGame_Id(Integer userId, Long gameId); + + Page findByUser_UserId(Integer userId, Pageable pageable); + + Long countByUser_UserId(Integer userId); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepositoryCustom.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepositoryCustom.java new file mode 100644 index 0000000..e3e8a3c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepositoryCustom.java @@ -0,0 +1,21 @@ +package com.gg.application.review.repository; + +import com.gg.application.review.dto.request.ReviewListRequestDto; +import com.gg.application.review.dto.response.ReviewListResponseDto; +import com.gg.application.review.entity.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface ReviewRepositoryCustom { + Page searchGameReviews( + Long gameId, + ReviewListRequestDto req, + String sort, // helpful | rating | playtime | createdAt + String order, // asc | desc + Pageable pageable + ); + Page findMyReviewsFiltered(Integer userId, Pageable pageable, boolean applyFilter); + Optional findMyReviewFiltered(Integer userId, Long gameId, boolean applyFilter); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepositoryImpl.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepositoryImpl.java new file mode 100644 index 0000000..66ad5ee --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/repository/ReviewRepositoryImpl.java @@ -0,0 +1,294 @@ +package com.gg.application.review.repository; + +import com.gg.application.review.dto.request.ReviewListRequestDto; +import com.gg.application.review.dto.response.ReviewListResponseDto; +import com.gg.application.review.entity.QReview; +import com.gg.application.review.entity.QReviewHelpful; +import com.gg.application.review.entity.Review; +import com.gg.common.query.GameFilterPredicates; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class ReviewRepositoryImpl implements ReviewRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final ReviewHelpfulRepository reviewHelpfulRepository; + private final GameFilterPredicates gameFilterPredicates; + + @Override + public Page searchGameReviews( + Long gameId, + ReviewListRequestDto req, + String sort, + String order, + Pageable pageable + ) { + String sortKey = (sort == null || sort.isBlank()) ? "helpful" : sort; + + // helpful 정렬은 전용 로직 태움 (count를 따로 집계해야해서 상관 서브쿼리 발생) + if ("helpful".equalsIgnoreCase(sortKey)) { + return fetchByHelpful(gameId, req, order, pageable); + } + + // 그 외 정렬 + return fetchNormal(gameId, req, sortKey, order, pageable); + } + + /** + * rating / playtime / updatedAt 정렬용 + */ + private Page fetchNormal( + Long gameId, + ReviewListRequestDto req, + String sort, + String order, + Pageable pageable + ) { + QReview r = QReview.review; + BooleanBuilder where = buildWhere(r, gameId, req); + + boolean asc = "asc".equalsIgnoreCase(order); + OrderSpecifier primary = switch (sort) { + case "rating" -> asc ? r.rating.asc() : r.rating.desc(); + case "playtime" -> asc ? r.playtime.asc() : r.playtime.desc(); + default -> asc ? r.updatedAt.asc() : r.updatedAt.desc(); + }; + + // 1) 리뷰 페이지 조회 + List rows = queryFactory + .select(Projections.fields( + ReviewListResponseDto.class, + r.id.as("reviewId"), + r.user.userId.as("authorId"), + r.user.userName.as("authorName"), + r.game.id.as("gameId"), + r.content.as("content"), + r.rating.as("rating"), + r.playtime.as("playtime"), + r.updatedAt.as("createdAt") + )) + .from(r) + .where(where) + .orderBy(primary, r.updatedAt.desc(), r.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // total + Long total = queryFactory + .select(r.id.count()) + .from(r) + .where(where) + .fetchOne(); + long totalElements = total == null ? 0L : total; + + // 리뷰가 없으면 바로 반환 + if (rows.isEmpty()) { + return new PageImpl<>(rows, pageable, totalElements); + } + + // 2) 페이지에 있는 리뷰들만 helpful group by + List reviewIds = rows.stream() + .map(ReviewListResponseDto::getReviewId) + .toList(); + + var counts = reviewHelpfulRepository.countByReviewIdsGroupBy(reviewIds); + Map countMap = counts.stream() + .collect(Collectors.toMap( + ReviewHelpfulRepository.CountProjection::getReviewId, + ReviewHelpfulRepository.CountProjection::getCnt + )); + + // 3) 병합 + List merged = rows.stream() + .map(d -> ReviewListResponseDto.builder() + .reviewId(d.getReviewId()) + .authorId(d.getAuthorId()) + .authorName(d.getAuthorName()) + .gameId(d.getGameId()) + .content(d.getContent()) + .rating(d.getRating()) + .playtime(d.getPlaytime()) + .helpfulCount(countMap.getOrDefault(d.getReviewId(), 0L)) + .createdAt(d.getCreatedAt()) + .build() + ) + .toList(); + + return new PageImpl<>(merged, pageable, totalElements); + } + + /** + * helpful desc/asc 정렬용 쿼리 + * 1) review + review_helpfuls group by 해서 id와 count를 페이지로 가져오고 + * 2) 그 id들로 실제 리뷰 정보를 다시 조회해서 + * 3) 순서를 원래대로 맞춰서 반환 + */ + private Page fetchByHelpful( + Long gameId, + ReviewListRequestDto req, + String order, + Pageable pageable + ) { + QReview r = QReview.review; + QReviewHelpful rh = QReviewHelpful.reviewHelpful; + + BooleanBuilder where = buildWhere(r, gameId, req); + boolean asc = "asc".equalsIgnoreCase(order); + + // 1) helpful 기준으로 review id와 count를 페이지 단위로 가져오기 + List idTuples = queryFactory + .select(r.id, rh.id.count()) + .from(r) + .leftJoin(rh).on(rh.review.id.eq(r.id)) + .where(where) + .groupBy(r.id) + .orderBy( + asc ? rh.id.count().asc() : rh.id.count().desc(), + r.updatedAt.desc(), + r.id.desc() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + if (idTuples.isEmpty()) { + // total도 같이 반환 + Long total = queryFactory + .select(r.id.count()) + .from(r) + .where(where) + .fetchOne(); + return new PageImpl<>(List.of(), pageable, total == null ? 0L : total); + } + + // id와 helpfulCount를 분리 + List reviewIdsInOrder = idTuples.stream() + .map(t -> t.get(r.id)) + .toList(); + + Map helpfulCountMap = idTuples.stream() + .collect(Collectors.toMap( + t -> t.get(r.id), + t -> t.get(rh.id.count()) + )); + + // 2) 위에서 구한 id들로 실제 리뷰 정보 조회 + List rows = queryFactory + .select(Projections.fields( + ReviewListResponseDto.class, + r.id.as("reviewId"), + r.user.userId.as("authorId"), + r.user.userName.as("authorName"), + r.game.id.as("gameId"), + r.content.as("content"), + r.rating.as("rating"), + r.playtime.as("playtime"), + r.updatedAt.as("createdAt") + )) + .from(r) + .where(r.id.in(reviewIdsInOrder)) + .fetch(); + + // in(...)으로 가져온 건 순서가 보장 안 되니까 다시 맞춰주기 + Map dtoMap = rows.stream() + .collect(Collectors.toMap(ReviewListResponseDto::getReviewId, d -> d)); + + List finalRows = reviewIdsInOrder.stream() + .map(id -> { + ReviewListResponseDto d = dtoMap.get(id); + return ReviewListResponseDto.builder() + .reviewId(d.getReviewId()) + .authorId(d.getAuthorId()) + .authorName(d.getAuthorName()) + .gameId(d.getGameId()) + .content(d.getContent()) + .rating(d.getRating()) + .playtime(d.getPlaytime()) + .createdAt(d.getCreatedAt()) + .helpfulCount(helpfulCountMap.getOrDefault(id, 0L)) + .build(); + }) + .toList(); + + // 3) total은 전체 리뷰 수로 + Long total = queryFactory + .select(r.id.count()) + .from(r) + .where(where) + .fetchOne(); + + return new PageImpl<>(finalRows, pageable, total == null ? 0L : total); + } + + private BooleanBuilder buildWhere(QReview r, Long gameId, ReviewListRequestDto req) { + BooleanBuilder where = new BooleanBuilder() + .and(r.game.id.eq(gameId)); + if (req == null) return where; + + if (req.getMinRating() != null) { + where.and(r.rating.goe(req.getMinRating())); + } + if (req.getMinPlaytime() != null) { + where.and(r.playtime.goe(req.getMinPlaytime())); + } + + LocalDate start = req.getStart(); + LocalDate end = req.getEnd(); + if (start != null) { + where.and(r.updatedAt.goe(start.atStartOfDay())); + } + if (end != null) { + where.and(r.updatedAt.loe(end.atTime(23, 59, 59))); + } + + return where; + } + + @Override + public Page findMyReviewsFiltered(Integer userId, Pageable pageable, boolean applyFilter) { + QReview r = QReview.review; + + var where = new BooleanBuilder() + .and(r.user.userId.eq(userId)) + .and(gameFilterPredicates.excludeGamesHavingAllThreeTagsOn(r.game.id, applyFilter)); + + List rows = queryFactory + .selectFrom(r) + .where(where) + .orderBy(r.createdAt.desc(), r.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(r.id.count()).from(r).where(where).fetchOne(); + return new PageImpl<>(rows, pageable, total == null ? 0L : total); + } + + @Override + public Optional findMyReviewFiltered(Integer userId, Long gameId, boolean applyFilter) { + QReview r = QReview.review; + + Review row = queryFactory + .selectFrom(r) + .where( + r.user.userId.eq(userId), + r.game.id.eq(gameId), + gameFilterPredicates.excludeGamesHavingAllThreeTagsOn(r.game.id, applyFilter) + ) + .fetchOne(); + return Optional.ofNullable(row); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/service/ReviewHelpfulService.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/service/ReviewHelpfulService.java new file mode 100644 index 0000000..eed1435 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/service/ReviewHelpfulService.java @@ -0,0 +1,84 @@ +package com.gg.application.review.service; + +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.review.dto.response.MyLikedReviewResponseDto; +import com.gg.application.review.entity.Review; +import com.gg.application.review.entity.ReviewHelpful; +import com.gg.application.review.entity.ReviewHelpfulId; +import com.gg.application.review.repository.ReviewHelpfulRepository; +import com.gg.application.review.repository.ReviewRepository; +import com.gg.application.game.service.GameCacheService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReviewHelpfulService { + + private final ReviewHelpfulRepository reviewHelpfulRepository; + private final ReviewRepository reviewRepository; + private final GameCacheService gameCacheService; + + @Transactional + public void addHelpful(Integer reviewId, Integer userId) { + if (!reviewRepository.existsById(reviewId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "리뷰가 존재하지 않습니다."); + } + + reviewHelpfulRepository.insertIfAbsent(reviewId, userId); + } + + @Transactional + public void deleteHelpful(Integer reviewId, Integer userId) { + if (!reviewRepository.existsById(reviewId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "리뷰가 존재하지 않습니다."); + } + + ReviewHelpfulId id = new ReviewHelpfulId(reviewId, userId); + + if(!reviewHelpfulRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 리뷰 도움은 존재하지 않습니다."); + } + + reviewHelpfulRepository.deleteById(id); + } + + @Transactional(readOnly = true) + public PageResponse getMyHelpfulReviews(Integer userId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + + boolean enabled = true; // 기본 적용 + if (userId != null) { + // Redis 캐시에서 조회 + Boolean cached = gameCacheService.getTagFilterEnabled(userId); + enabled = cached != null ? cached : true; + } + + Page p = reviewHelpfulRepository.findMyHelpfulReviewsFiltered(userId, pageable, enabled); + + List content = p.getContent().stream() + .map(rh -> { + Review review = rh.getReview(); + return MyLikedReviewResponseDto.builder() + .reviewId(review.getId()) + .gameId(review.getGame().getId()) + .gameName(review.getGame().getGameName()) + .authorName(review.getUser().getUserName()) + .rating(review.getRating()) + .playtime(review.getPlaytime()) + .content(review.getContent()) + .createdAt(review.getCreatedAt()) + .build(); + }) + .toList(); + + Page mapped = new PageImpl<>(content, pageable, p.getTotalElements()); + return PageResponse.of(mapped, "id", "desc"); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/review/service/ReviewService.java b/gg_web/gg_backend/src/main/java/com/gg/application/review/service/ReviewService.java new file mode 100644 index 0000000..937ba32 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/review/service/ReviewService.java @@ -0,0 +1,260 @@ +package com.gg.application.review.service; + +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.game.entity.Game; +import com.gg.application.game.repository.GameRepository; +import com.gg.application.review.dto.request.ReviewListRequestDto; +import com.gg.application.review.dto.request.ReviewRequestDto; +import com.gg.application.review.dto.response.MyReviewListItemDto; +import com.gg.application.review.dto.response.MyReviewResponseDto; +import com.gg.application.review.dto.response.ReviewListResponseDto; +import com.gg.application.review.entity.Review; +import com.gg.application.review.repository.ReviewHelpfulRepository; +import com.gg.application.review.repository.ReviewHelpfulRepository.CountProjection; +import com.gg.application.review.repository.ReviewRepository; +import com.gg.application.user.entity.OwnedGame; +import com.gg.application.user.entity.User; +import com.gg.application.user.repository.OwnedGameRepository; +import com.gg.application.user.repository.UserRepository; +import com.gg.application.game.service.GameService; +import com.gg.application.game.service.GameCacheService; +import com.gg.common.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final UserRepository userRepository; + private final GameRepository gameRepository; + private final ReviewRepository reviewRepository; + private final OwnedGameRepository ownedGameRepository; + private final ReviewHelpfulRepository reviewHelpfulRepository; + private final GameService gameService; + private final GameCacheService gameCacheService; + + @Transactional + public void createReview(Long gameId, Integer userId, ReviewRequestDto req) { + if (req == null) throw new IllegalArgumentException("body is required"); + if (req.getContent() == null || req.getContent().isBlank()) + throw new IllegalArgumentException("내용이 있어야 합니다."); + if (req.getContent().length() > 100) + throw new IllegalArgumentException("리뷰 내용은 100자 이하여야 합니다."); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("user not found")); + Game game = gameRepository.findById(gameId) + .orElseThrow(() -> new IllegalArgumentException("game not found")); + + // 게임 보유자만 작성 가능 + OwnedGame ownedGame = ownedGameRepository.findByUser_UserIdAndGame_Id(userId, gameId) + .orElseThrow(() -> new IllegalStateException("보유하지 않은 게임에는 리뷰를 작성할 수 없습니다.")); + + // 이미 리뷰 작성한게 있다면 + if (reviewRepository.existsByUser_UserIdAndGame_Id(userId, gameId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 이 게임에 작성한 리뷰가 있습니다."); + } + + // 평점 범위 확인 + if (req.getRating() != null) { + float r = req.getRating(); + if (r < 0.0f || r > 5.0f) { + throw new IllegalArgumentException("평점은 0.0~5.0 범위여야 합니다."); + } + } + + float playtimeHours = Math.round((ownedGame.getPlaytimeForever() / 60f) * 10f) / 10f; + + Review review = Review.of(user, game, req.getContent(), req.getRating(), playtimeHours); + reviewRepository.save(review); + + // 게임 평점 갱신 + gameService.refreshGameRating(gameId); + } + + @Transactional + public void deleteMyReview(Integer reviewId, Integer userId) { + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new NotFoundException("리뷰가 존재하지 않습니다.")); + + if (!review.getUser().getUserId().equals(userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "삭제 권한이 없습니다."); + } + + Long gameId = review.getGame().getId(); + + reviewHelpfulRepository.deleteAllByReviewId(reviewId); + + reviewRepository.delete(review); + + // 게임 평점 갱신 + gameService.refreshGameRating(gameId); + } + + @Transactional + public void updateMyReview(Integer reviewId, Long gameId, Integer userId, ReviewRequestDto req){ + if (req.getContent() == null || req.getContent().isBlank()) + throw new IllegalArgumentException("내용이 있어야 합니다."); + if (req.getContent().length() > 100) + throw new IllegalArgumentException("리뷰 내용은 100자 이하여야 합니다."); + + Review review = reviewRepository.findByIdAndUser_UserId(reviewId, userId) + .orElseThrow(() -> new IllegalArgumentException("리뷰가 없거나 권한이 없습니다.")); + + OwnedGame ownedGame = ownedGameRepository.findByUser_UserIdAndGame_Id(userId, gameId) + .orElseThrow(() -> new IllegalStateException("보유하지 않은 게임에는 리뷰를 작성할 수 없습니다.")); + + if (req.getRating() != null) { + float r = req.getRating(); + if (r < 0.0f || r > 5.0f) { + throw new IllegalArgumentException("평점은 0.0~5.0 범위여야 합니다."); + } + } + + float playtimeHours = Math.round((ownedGame.getPlaytimeForever() / 60f) * 10f) / 10f; + + // 평점이 변경되었는지 확인 + boolean ratingChanged = req.getRating() != null && + !req.getRating().equals(review.getRating()); + + review.update(req.getContent(), req.getRating(), playtimeHours); + + // 평점이 변경되었으면 갱신 + if (ratingChanged) { + gameService.refreshGameRating(gameId); + } + } + + @Transactional(readOnly = true) + public PageResponse getGameReviews( + Long gameId, + ReviewListRequestDto req, + int page, int size, + String sort, String order, + Integer userId // 로그인 안 했으면 null + ) { + Pageable pageable = PageRequest.of(page, size); + + // 1) Impl 쿼리 호출 (myHelpful=false 상태로 들어옴) + Page p = reviewRepository.searchGameReviews(gameId, req, sort, order, pageable); + + String normalizedSort = (sort == null || sort.isBlank()) ? "helpful" : sort; + String normalizedOrder = (order == null || order.isBlank()) ? "desc" : order; + + // 2) 로그인 시에만 ‘내 도움’ 병합 + if (userId == null || p.isEmpty()) { + return PageResponse.of(p, normalizedSort, normalizedOrder); + } + + List reviewIds = p.getContent().stream() + .map(ReviewListResponseDto::getReviewId) + .toList(); + + Set myIds = reviewHelpfulRepository.findMyHelpfulIds(userId, reviewIds); + + // 3) myHelpful 적용해서 새 리스트로 재구성 + List updated = p.getContent().stream() + .map(d -> ReviewListResponseDto.builder() + .reviewId(d.getReviewId()) + .authorId(d.getAuthorId()) + .authorName(d.getAuthorName()) + .gameId(d.getGameId()) + .content(d.getContent()) + .rating(d.getRating()) + .playtime(d.getPlaytime()) + .helpfulCount(d.getHelpfulCount()) + .myHelpful(myIds.contains(d.getReviewId())) + .createdAt(d.getCreatedAt()) + .build() + ) + .toList(); + + Page merged = new PageImpl<>(updated, pageable, p.getTotalElements()); + + return PageResponse.of(merged, normalizedSort, normalizedOrder); + } + + @Transactional(readOnly = true) + public MyReviewResponseDto getMyReview(Long gameId, Integer userId) { + + boolean enabled = true; + if (userId == null) enabled = true; + else { + // Redis 캐시에서 조회 + Boolean cached = gameCacheService.getTagFilterEnabled(userId); + enabled = cached != null ? cached : true; + } + + return reviewRepository.findMyReviewFiltered(userId, gameId, enabled) + .map(r -> { + Long helpfulCount = reviewHelpfulRepository.countByReview_Id(r.getId()); + Boolean myHelpful = reviewHelpfulRepository.existsByReview_IdAndUser_UserId(r.getId(), userId); + return new MyReviewResponseDto( + r.getId(), + r.getUser().getUserName(), + r.getRating(), + r.getPlaytime(), + r.getContent(), + helpfulCount, + myHelpful, + r.getCreatedAt() + ); + }) + .orElse(null); + } + + @Transactional(readOnly = true) + public PageResponse getMyReviewList(Integer userId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + boolean enabled = true; + if (userId == null) enabled = true; + else { + // Redis 캐시에서 조회 + Boolean cached = gameCacheService.getTagFilterEnabled(userId); + enabled = cached != null ? cached : true; + } + + // 내 리뷰 목록 + Page p = reviewRepository.findMyReviewsFiltered(userId, pageable, enabled); + List reviews = p.getContent(); + + // helpfulCount 일괄 조회 + final Map helpfulMap = + reviews.isEmpty() + ? Collections.emptyMap() + : reviewHelpfulRepository.countByReviewIdsGroupBy( + reviews.stream().map(Review::getId).collect(Collectors.toList()) + ).stream() + .collect(Collectors.toMap(CountProjection::getReviewId, CountProjection::getCnt)); + + // DTO 매핑 + List content = reviews.stream() + .map(r -> MyReviewListItemDto.builder() + .reviewId(r.getId()) + .gameId(r.getGame().getId()) + .gameName(r.getGame().getGameName()) + .rating(r.getRating()) + .playtime(r.getPlaytime()) + .content(r.getContent()) + .helpfulCount(helpfulMap.getOrDefault(r.getId(), 0L)) + .createdAt(r.getCreatedAt()) + .updatedAt(r.getUpdatedAt()) + .build()) + .toList(); + + Page mapped = new PageImpl<>(content, pageable, p.getTotalElements()); + return PageResponse.of(mapped, "createdAt", "desc"); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/LikeGamesController.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/LikeGamesController.java new file mode 100644 index 0000000..a80000a --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/LikeGamesController.java @@ -0,0 +1,52 @@ +package com.gg.application.user.controller; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.user.dto.response.LikeGameResponseDto; +import com.gg.application.user.service.LikeGameService; +import com.gg.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/wishlist") +@RequiredArgsConstructor +public class LikeGamesController { + + private final LikeGameService likeGameService; + + @GetMapping("/me") + @Operation(summary = "위시 리스트 조회", description = "필터링 지원 (정렬 없음)") + public ResponseEntity> getLikeGames( + @ParameterObject GameListRequestDto req, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + List games = likeGameService.getLikeGames(customUserDetails, req); + return ResponseEntity.ok(games); + } + + @PostMapping("/me") + public ResponseEntity addLikeGames(@AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestParam Long gameId){ + try{ + likeGameService.addLikeGames(customUserDetails.getUserId(), gameId); + return ResponseEntity.ok().build(); + }catch (Exception e){ + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + } + + @DeleteMapping("/me/{gameId}") + public ResponseEntity deleteLikeGames(@AuthenticationPrincipal CustomUserDetails customUserDetails , @PathVariable Long gameId){ + try{ + likeGameService.deleteLikeGames(customUserDetails.getUserId(), gameId); + return ResponseEntity.ok().build(); + }catch (Exception e){ + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/OwnedGameController.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/OwnedGameController.java new file mode 100644 index 0000000..c9d85b1 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/OwnedGameController.java @@ -0,0 +1,60 @@ +package com.gg.application.user.controller; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.user.dto.response.OwnedGameListResponseDto; +import com.gg.application.user.service.OwnedGameService; +import com.gg.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/library") +public class OwnedGameController { + + private final OwnedGameService ownedGameService; + + @PostMapping("/me") + @Operation(summary = "보유 게임 연동(비동기)") + public ResponseEntity syncOwnedGamesAsync(@AuthenticationPrincipal CustomUserDetails userDetails) { + ownedGameService.syncOwnedGames(userDetails); + return ResponseEntity.accepted().build(); + } + + @PostMapping("/develop") + @Operation(summary = "개발자용 보유 게임 연동", description = "유저 번호와 스팀 아이디로 연동") + public ResponseEntity syncOwnedGameDeveloper(Long steamId, Integer userId) { + ownedGameService.syncOwnedGamesDevelopers(steamId, userId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/me") + @Operation(summary = "보유 게임 목록 조회", description = "필터링 및 정렬 지원") + public ResponseEntity> getOwnedGames( + @ParameterObject GameListRequestDto req, + @Parameter(description = "페이지(0-base)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "정렬 키(rating|price|releaseDate|like|ownedPlaytimeForever)") @RequestParam(defaultValue = "ownedPlaytimeForever") String sort, + @Parameter(description = "정렬 방향(asc|desc)") @RequestParam(defaultValue = "desc") String order, + @AuthenticationPrincipal CustomUserDetails userDetails) { + PageResponse result = ownedGameService.getOwnedGames(userDetails, req, page, size, sort, order); + return ResponseEntity.ok(result); + } + + @GetMapping("/develop") + @Operation(summary = "개발자용 보유 게임 목록 조회", description = "유저 번호로 검색") + public ResponseEntity> getOwnedGamesDeveloper( + Integer userId) { + List ownedGames = ownedGameService.getOwnedGamesDeveloper(userId); + return ResponseEntity.ok(ownedGames); + } + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/UserController.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/UserController.java new file mode 100644 index 0000000..95e4625 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/controller/UserController.java @@ -0,0 +1,67 @@ +package com.gg.application.user.controller; + +import com.gg.application.user.dto.request.UserTagFilterRequestDto; +import com.gg.application.user.dto.response.UserAvatarResponseDto; +import com.gg.application.user.dto.response.UserSummaryResponseDto; +import com.gg.application.user.dto.response.UserTagFilterResponseDto; +import com.gg.application.user.service.UserService; +import com.gg.config.security.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @DeleteMapping("/") + public ResponseEntity deleteUser(@AuthenticationPrincipal CustomUserDetails user) { + if (user == null) { + return ResponseEntity.status(401).build(); + } + userService.deleteUser(user.getUserId()); + return ResponseEntity.ok().build(); + } + + @GetMapping("/summary") + public ResponseEntity getSummary( + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + + UserSummaryResponseDto dto = userService.getSummary(user.getUserId()); + return ResponseEntity.ok(dto); + } + + @GetMapping("/tag") + public ResponseEntity getTagFilter( + @AuthenticationPrincipal CustomUserDetails user + ) { + UserTagFilterResponseDto dto = userService.getUserTagFilter(user); + return ResponseEntity.ok(dto); + } + + @PatchMapping("/tag") + public ResponseEntity updateTagFilter( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody UserTagFilterRequestDto request + ) { + UserTagFilterResponseDto dto = userService.updateUserTagFilter(user, request.getEnabled()); + return ResponseEntity.ok(dto); + } + + @GetMapping("/avatar") + public ResponseEntity getAvatar( + @AuthenticationPrincipal CustomUserDetails user + ) { + if (user == null) return ResponseEntity.status(401).build(); + + UserAvatarResponseDto dto = userService.getAvatar(user.getUserId()); + return ResponseEntity.ok(dto); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/OwnedGameListInfoDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/OwnedGameListInfoDto.java new file mode 100644 index 0000000..bf1da56 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/OwnedGameListInfoDto.java @@ -0,0 +1,21 @@ +package com.gg.application.user.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Builder +@Getter +@AllArgsConstructor +public class OwnedGameListInfoDto { + + private Long gameId; + private String gameName; + private Float rating; + private Integer likeCount; + private Integer playtime2weeks; + private Integer playtimeForever; + private String headerImage; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/OwnedGameRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/OwnedGameRequestDto.java new file mode 100644 index 0000000..3929ee9 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/OwnedGameRequestDto.java @@ -0,0 +1,15 @@ +package com.gg.application.user.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Builder +@Getter +public class OwnedGameRequestDto { + private Integer userId; + private Long gameId; + private Integer playtime2weeks; + private Integer playtimeForever; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/UserTagFilterRequestDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/UserTagFilterRequestDto.java new file mode 100644 index 0000000..2222a85 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/request/UserTagFilterRequestDto.java @@ -0,0 +1,14 @@ +package com.gg.application.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; + +@Getter +@NoArgsConstructor +public class UserTagFilterRequestDto { + @Schema(description = "true면 필터 적용(=on), false면 필터 미적용(=off)", example = "true") + @NotNull + private Boolean enabled; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/LikeGameResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/LikeGameResponseDto.java new file mode 100644 index 0000000..b6c2586 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/LikeGameResponseDto.java @@ -0,0 +1,20 @@ +package com.gg.application.user.dto.response; + +import lombok.*; + +import java.util.List; + +@Getter +@Builder +public class LikeGameResponseDto { + private Long gameId; + private String gameName; + private Integer gameInitialPrice; + private Integer gameDiscount; + private Integer gameFinalPrice; + private Float rating; + private String headerImage; + private Integer likeCount; + + private List genres; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/OwnedGameListResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/OwnedGameListResponseDto.java new file mode 100644 index 0000000..8131ca3 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/OwnedGameListResponseDto.java @@ -0,0 +1,23 @@ +package com.gg.application.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class OwnedGameListResponseDto { + + private Long gameId; + private String gameName; + private Float rating; + private Integer likeCount; + private Integer playtime2weeks; + private Integer playtimeForever; + private String headerImage; + + private List genres; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserAvatarResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserAvatarResponseDto.java new file mode 100644 index 0000000..f8f958a --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserAvatarResponseDto.java @@ -0,0 +1,14 @@ +package com.gg.application.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class UserAvatarResponseDto { + @Schema(description = "유저 프로필 사진") + private String avatarUrl; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserSummaryResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserSummaryResponseDto.java new file mode 100644 index 0000000..829ad5b --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserSummaryResponseDto.java @@ -0,0 +1,26 @@ +package com.gg.application.user.dto.response; + +import lombok.*; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserSummaryResponseDto { + private Long reviewCount; // 내가 작성한 리뷰 수 + private Long totalHelpfulCount; // 총 도움돼요 받은 수 + private List ownedGenreRatio; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class GenreRatio { + private String genre; + private double ratio; // 보유 장르 비율 + private Float totalPlaytime; // 장르별 총 플레이타임(시간) + private Float recentPlaytime; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserTagFilterResponseDto.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserTagFilterResponseDto.java new file mode 100644 index 0000000..955a474 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/dto/response/UserTagFilterResponseDto.java @@ -0,0 +1,15 @@ +package com.gg.application.user.dto.response; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class UserTagFilterResponseDto { + @Schema(description = "현재 필터 적용 여부", example = "true") + private boolean enabled; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/LikeGame.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/LikeGame.java new file mode 100644 index 0000000..cc99e70 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/LikeGame.java @@ -0,0 +1,25 @@ +package com.gg.application.user.entity; + +import com.gg.application.game.entity.Game; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "like_games") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LikeGame { + + @EmbeddedId + private LikeGameId id; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("userId") + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("gameId") + @JoinColumn(name = "game_id", nullable = false) + private Game game; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/LikeGameId.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/LikeGameId.java new file mode 100644 index 0000000..15626b9 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/LikeGameId.java @@ -0,0 +1,17 @@ +package com.gg.application.user.entity; + +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor +public class LikeGameId implements Serializable { + private Integer userId; + private Long gameId; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/OwnedGame.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/OwnedGame.java new file mode 100644 index 0000000..43f6a52 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/OwnedGame.java @@ -0,0 +1,50 @@ +package com.gg.application.user.entity; + +import com.gg.application.game.entity.Game; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "owned_games") +@NoArgsConstructor +@Getter +public class OwnedGame { + + @EmbeddedId + private OwnedGameId id; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("userId") + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("gameId") + @JoinColumn(name = "game_id", nullable = false) + private Game game; + + @Column(name = "playtime_2weeks", nullable = false, columnDefinition = "int default 0") + private Integer playtime2weeks; + + @Column(name = "playtime_forever", nullable = false, columnDefinition = "int default 0") + private Integer playtimeForever; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "is_deleted", nullable = false, columnDefinition = "boolean default false") + private Boolean isDeleted; +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/OwnedGameId.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/OwnedGameId.java new file mode 100644 index 0000000..1e7e0a1 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/OwnedGameId.java @@ -0,0 +1,33 @@ +package com.gg.application.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +import java.io.Serializable; +import java.util.Objects; + +@Embeddable +@Getter +public class OwnedGameId implements Serializable { + + @Column(name = "user_id") + private Integer userId; + + @Column(name = "game_id") + private Long gameId; + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof OwnedGameId)) return false; + OwnedGameId ownedGameId = (OwnedGameId) obj; + return Objects.equals(userId, ownedGameId.userId) && + Objects.equals(gameId, ownedGameId.gameId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, gameId); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/User.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/User.java new file mode 100644 index 0000000..bd36de1 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/entity/User.java @@ -0,0 +1,100 @@ +package com.gg.application.user.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicUpdate; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Getter +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicUpdate +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL + @Column(name = "user_id") + private Integer userId; + + @Column(name = "steam_id", nullable = false) + private Long steamId; + + @Column(name = "user_name", nullable = false, length = 20) + private String userName; + + @Column(name = "refresh_token") + private String refreshToken; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "is_deleted", nullable = false, columnDefinition = "boolean default false") + private boolean isDeleted; + + @Column(name = "tag_filter_enabled", nullable = false, columnDefinition = "boolean default true") + private boolean tagFilterEnabled; + + @Column(name = "avatar_url") // 아바타 URL 추가 + private String avatarUrl; + + @Builder + public User(Long steamId, String userName, String avatarUrl, Boolean tag) { + this.steamId = steamId; + this.userName = userName; + this.avatarUrl = avatarUrl; + this.tagFilterEnabled = tag; + } + + /** + * 삭제 처리 메서드 (Soft Delete) + */ + public void softDelete() { + this.isDeleted = true; + this.deletedAt = LocalDateTime.now(); + this.refreshToken = null; + } + + // 삭제 복구 처리 + public void reactivate(String name) { + this.isDeleted = false; + this.deletedAt = null; + this.userName = name; + } + + public void reactivate(String name, String avatarUrl) { + this.isDeleted = false; + this.deletedAt = null; + this.userName = name; + this.avatarUrl = avatarUrl; + } + + /** + * 닉네임 변경 등 업데이트 시 호출 + */ + public void updateUserName(String newName) { + this.userName = newName; + } + + public void updateAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepository.java new file mode 100644 index 0000000..7aba281 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepository.java @@ -0,0 +1,68 @@ +package com.gg.application.user.repository; + +import com.gg.application.user.entity.LikeGame; +import com.gg.application.user.entity.LikeGameId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface LikeGameRepository extends JpaRepository, LikeGameRepositoryCustom { + + @Query("select lg " + + "from LikeGame lg " + + "join fetch lg.game g " + + "where lg.id.userId = :userId") + List findAllById(Integer userId); + + @Query(""" + select lg from LikeGame lg + join fetch lg.game g + where lg.user.userId = :userId + """) + List findAllByUser_UserIdWithGame(@Param("userId") Integer userId); + + // 추가: 이미 있으면 아무 일도 안 함(idempotent) + // 단일 쿼리로 like_games 추가와 games.like_count 증가를 원자적으로 처리 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = """ + WITH inserted AS ( + INSERT INTO like_games (user_id, game_id) + VALUES (:userId, :gameId) + ON CONFLICT (user_id, game_id) DO NOTHING + RETURNING game_id + ) + UPDATE games + SET like_count = like_count + 1, updated_at = NOW() + WHERE game_id = :gameId + AND EXISTS (SELECT 1 FROM inserted) + """, + nativeQuery = true + ) + int insertIfNotExists(Integer userId, Long gameId); + + // 삭제: 없으면 0행 반환(idempotent) + // 단일 쿼리로 like_games 삭제와 games.like_count 감소를 원자적으로 처리 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = """ + WITH deleted AS ( + DELETE FROM like_games + WHERE user_id = :userId AND game_id = :gameId + RETURNING game_id + ) + UPDATE games + SET like_count = GREATEST(like_count - 1, 0), updated_at = NOW() + WHERE game_id = :gameId + AND EXISTS (SELECT 1 FROM deleted) + """, + nativeQuery = true + ) + int deleteByIds(Integer userId, Long gameId); +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepositoryCustom.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepositoryCustom.java new file mode 100644 index 0000000..737901d --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.gg.application.user.repository; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.user.dto.response.LikeGameResponseDto; + +import java.util.List; + +public interface LikeGameRepositoryCustom { + List searchLikeGames(Integer userId, GameListRequestDto req, boolean applyFilter); +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepositoryImpl.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepositoryImpl.java new file mode 100644 index 0000000..3846e4c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/LikeGameRepositoryImpl.java @@ -0,0 +1,289 @@ +package com.gg.application.user.repository; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.entity.AttributeType; +import com.gg.common.query.GameFilterPredicates; +import com.gg.application.user.dto.response.LikeGameResponseDto; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +import static com.gg.application.game.entity.QGame.game; +import static com.gg.application.game.entity.QGameAttribute.gameAttribute; +import static com.gg.application.game.entity.QAttribute.attribute; +import static com.gg.application.user.entity.QLikeGame.likeGame; + +@Repository +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class LikeGameRepositoryImpl implements LikeGameRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final GameFilterPredicates gameFilterPredicates; + + @Override + public List searchLikeGames(Integer userId, GameListRequestDto req, boolean applyFilter) { + BooleanBuilder where = buildWhereClause(userId, req, applyFilter); + + // 게임 ID 목록 조회 + List gameIds = queryFactory + .select(likeGame.game.id) + .distinct() + .from(likeGame) + .join(likeGame.game, game) + .where(where) + .fetch(); + + if (gameIds.isEmpty()) { + return List.of(); + } + + // 기본 데이터 조회 + List baseRows = queryFactory + .select( + likeGame.game.id, + likeGame.game.gameName, + likeGame.game.gameInitialPrice, + likeGame.game.gameDiscount, + likeGame.game.gameFinalPrice, + likeGame.game.headerImage, + game.likeCount, + game.avgRating + ) + .from(likeGame) + .join(likeGame.game, game) + .where(likeGame.game.id.in(gameIds) + .and(likeGame.user.userId.eq(userId))) + .fetch(); + + // genres 조회 + Map> genresMap = fetchGenresByGameIds(gameIds); + + // DTO 리스트 생성 (중복 제거) + Map dtoMap = new LinkedHashMap<>(); + for (Tuple row : baseRows) { + Long gameId = row.get(likeGame.game.id); + if (dtoMap.containsKey(gameId)) { + continue; // 이미 추가된 게임은 건너뛰기 + } + Float rating = row.get(game.avgRating); + Integer likeCount = row.get(game.likeCount); + + dtoMap.put(gameId, LikeGameResponseDto.builder() + .gameId(gameId) + .gameName(row.get(likeGame.game.gameName)) + .gameInitialPrice(row.get(likeGame.game.gameInitialPrice)) + .gameDiscount(row.get(likeGame.game.gameDiscount)) + .gameFinalPrice(row.get(likeGame.game.gameFinalPrice)) + .rating(rating != null ? rating : 0.0f) + .headerImage(row.get(likeGame.game.headerImage)) + .likeCount(likeCount != null ? likeCount : 0) + .genres(genresMap.getOrDefault(gameId, List.of())) + .build()); + } + return new ArrayList<>(dtoMap.values()); + } + + private BooleanBuilder buildWhereClause(Integer userId, GameListRequestDto req, boolean applyFilter) { + BooleanBuilder where = new BooleanBuilder(); + + // 사용자 위시 리스트 필터 + where.and(likeGame.user.userId.eq(userId)); + + // 게임 필터 + where.and(gameFilterPredicates.excludeGamesHavingAllThreeTags(applyFilter)); + + // 기본 필터 조건 + if (req.getQ() != null && !req.getQ().isBlank()) { + where.and(game.gameName.containsIgnoreCase(req.getQ())); + } + if (req.getMinPrice() != null) { + where.and(game.gameFinalPrice.goe(req.getMinPrice())); + } + if (req.getMaxPrice() != null) { + where.and(game.gameFinalPrice.loe(req.getMaxPrice())); + } + if (req.getDiscountFrom() != null) { + where.and(game.gameDiscount.goe(req.getDiscountFrom())); + } + if (req.getReleasedFrom() != null) { + where.and(game.releaseDate.goe(req.getReleasedFrom())); + } + if (req.getReleasedTo() != null) { + where.and(game.releaseDate.loe(req.getReleasedTo())); + } + + // OS 필터 (AND 조건: 여러 개 선택 시 모두 지원해야 함) + if (req.getPlatforms() != null && !req.getPlatforms().isEmpty()) { + BooleanBuilder os = new BooleanBuilder(); + for (String p : req.getPlatforms()) { + if (p == null) continue; + switch (p.toLowerCase()) { + case "windows" -> os.and(game.isWindow.isTrue()); + case "mac" -> os.and(game.isMac.isTrue()); + case "linux" -> os.and(game.isLinux.isTrue()); + } + } + if (os.hasValue()) { + where.and(os); + } + } + + // 언어 필터 + if (req.getLanguages() != null && !req.getLanguages().isEmpty()) { + List normalized = normalizeLanguages(req.getLanguages()); + where.and(existsLanguageIn(normalized)); + } + + // 속성 필터 + if (req.getGenres() != null && !req.getGenres().isEmpty()) { + where.and(existsAttributeIn(AttributeType.genre, req.getGenres())); + } + if (req.getTags() != null && !req.getTags().isEmpty()) { + where.and(existsAttributeIn(AttributeType.tag, req.getTags())); + } + if (req.getCategories() != null && !req.getCategories().isEmpty()) { + where.and(existsAttributeIn(AttributeType.category, req.getCategories())); + } + + // minRating 필터 + if (req.getMinRating() != null) { + NumberExpression ratingSubquery = avgRating(); + where.and(ratingSubquery.goe(req.getMinRating())); + } + + return where; + } + + private NumberExpression avgRating() { + return game.avgRating.coalesce(0.0f); + } + + private Map> fetchGenresByGameIds(List gameIds) { + if (gameIds.isEmpty()) { + return Map.of(); + } + + List genreRows = queryFactory + .select(gameAttribute.id.gameId, attribute.name) + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.in(gameIds) + .and(attribute.type.eq(AttributeType.genre)) + ) + .fetch(); + + return genreRows.stream() + .collect(Collectors.groupingBy( + row -> row.get(gameAttribute.id.gameId), + Collectors.mapping( + row -> row.get(attribute.name), + Collectors.toList() + ) + )); + } + + private BooleanExpression existsAttributeIn(AttributeType type, List attributeIds) { + if (attributeIds == null || attributeIds.isEmpty()) return null; + + if (attributeIds.size() == 1) { + return JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(type)) + .and(attribute.id.eq(attributeIds.get(0))) + ) + .exists(); + } else { + BooleanExpression result = null; + for (Integer attributeId : attributeIds) { + BooleanExpression exists = JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(type)) + .and(attribute.id.eq(attributeId)) + ) + .exists(); + if (result == null) { + result = exists; + } else { + result = result.and(exists); + } + } + return result; + } + } + + private BooleanExpression existsLanguageIn(List languageNames) { + if (languageNames == null || languageNames.isEmpty()) return null; + + if (languageNames.size() == 1) { + return JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.language)) + .and(attribute.name.eq(languageNames.get(0))) + ) + .exists(); + } else { + BooleanExpression result = null; + for (String languageName : languageNames) { + BooleanExpression exists = JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.language)) + .and(attribute.name.eq(languageName)) + ) + .exists(); + if (result == null) { + result = exists; + } else { + result = result.and(exists); + } + } + return result; + } + } + + private List normalizeLanguages(List langs) { + if (langs == null) return List.of(); + List result = new ArrayList<>(); + for (String lang : langs) { + if (lang == null) continue; + String lower = lang.toLowerCase(); + + switch (lower) { + case "en", "영어" -> result.add("영어"); + case "ko", "한국어" -> result.add("한국어"); + case "ja", "일본어" -> result.add("일본어"); + case "zh-cn", "중국어 간체" -> result.add("중국어 간체"); + case "zh-tw", "중국어 번체" -> result.add("중국어 번체"); + default -> result.add(lang); + } + } + return result; + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepository.java new file mode 100644 index 0000000..edd5928 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepository.java @@ -0,0 +1,72 @@ +package com.gg.application.user.repository; + +import com.gg.application.user.dto.request.OwnedGameListInfoDto; +import com.gg.application.user.dto.request.OwnedGameRequestDto; +import com.gg.application.user.entity.OwnedGame; +import com.gg.application.user.entity.OwnedGameId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface OwnedGameRepository extends JpaRepository, OwnedGameRepositoryCustom { + + List findByIdUserIdAndIdGameIdIn(Integer userId, Collection gameIds); + + @Query(""" + select new com.gg.application.user.dto.request.OwnedGameListInfoDto( + g.id, + g.gameName, + coalesce(g.avgRating, 0.0f), + g.likeCount, + og.playtime2weeks, + og.playtimeForever, + g.headerImage + ) + from OwnedGame og + join og.game g + where og.user.userId = :userId + and og.isDeleted = false + """) + List findDtoByUserIdAndGameId(@Param("userId") Integer userId); + + @Modifying + @Query(value = """ + INSERT INTO owned_games + (user_id, game_id, playtime_2weeks, playtime_forever, is_deleted, created_at, updated_at) + VALUES + (:#{#dto.userId}, :#{#dto.gameId}, COALESCE(:#{#dto.playtime2weeks},0), COALESCE(:#{#dto.playtimeForever},0), FALSE, now(), now()) + ON CONFLICT (user_id, game_id) DO NOTHING + """, nativeQuery = true) + int insertFromDto(@Param("dto") OwnedGameRequestDto dto); + + @Modifying + @Query(""" + UPDATE OwnedGame og + SET og.playtime2weeks = COALESCE(:#{#dto.playtime2weeks}, 0), + og.playtimeForever = COALESCE(:#{#dto.playtimeForever}, 0), + og.isDeleted = FALSE + WHERE og.id.userId = :#{#dto.userId} + AND og.id.gameId = :#{#dto.gameId} + """) + int updateFromDto(@Param("dto") OwnedGameRequestDto dto); + + + Optional findByUser_UserIdAndGame_Id(Integer userId, Long gameId); + + @Query(value = """ + select a.name as genre, count(*) as cnt, sum(og.playtime_forever) as totalPlaytime, sum(og.playtime_2weeks) as recentPlaytime + from owned_games og + join game_attributes ga on ga.game_id = og.game_id + join attributes a on a.attribute_id = ga.attribute_id + where og.user_id = :userId + and a.type = 'genre' + group by a.name + """, nativeQuery = true) + List findOwnedGenreCounts(@Param("userId") Integer userId); + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepositoryCustom.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepositoryCustom.java new file mode 100644 index 0000000..83ce194 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.gg.application.user.repository; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.user.dto.response.OwnedGameListResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface OwnedGameRepositoryCustom { + Page searchOwnedGames(Integer userId, GameListRequestDto req, Pageable pageable, boolean applyFilter); +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepositoryImpl.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepositoryImpl.java new file mode 100644 index 0000000..c3ba774 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/OwnedGameRepositoryImpl.java @@ -0,0 +1,452 @@ +package com.gg.application.user.repository; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.service.GameCacheService; +import com.gg.common.query.GameFilterPredicates; +import com.gg.application.user.dto.response.OwnedGameListResponseDto; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.ComparableExpressionBase; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.stream.Collectors; + +import static com.gg.application.game.entity.QGame.game; +import static com.gg.application.game.entity.QGameAttribute.gameAttribute; +import static com.gg.application.game.entity.QAttribute.attribute; +import static com.gg.application.user.entity.QOwnedGame.ownedGame; + +@Repository +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class OwnedGameRepositoryImpl implements OwnedGameRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final GameFilterPredicates gameFilterPredicates; + @Lazy + private final GameCacheService gameCacheService; + @Override + public Page searchOwnedGames(Integer userId, GameListRequestDto req, Pageable pageable, boolean applyFilter) { + BooleanBuilder where = buildWhereClause(userId, req, applyFilter); + Sort sort = pageable.getSort(); + + boolean needsRatingSort = isRatingSort(sort); + + // 필터 조건 기반 캐시 키 생성 (정렬은 count에 영향 없으므로 제외) + String filterHash = generateFilterHash(userId, req, applyFilter); + + // 1) total count - 캐시에서 먼저 확인 + Long total = gameCacheService.getCachedCount(filterHash); + + if (total == null) { + total = queryFactory + .select(ownedGame.count()) + .from(ownedGame) + .join(ownedGame.game, game) + .where(where) + .fetchOne(); + + if (total != null) { + gameCacheService.setCachedCount(filterHash, total); + } + } + + long totalCount = (total == null ? 0L : total); + if (totalCount == 0) { + return Page.empty(pageable); + } + + // 2) 페이지 대상 id 목록 + List pageIds; + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.addAll(Arrays.asList(resolveOwnedGameOrder(sort))); + if (needsRatingSort) { + orderSpecifiers.add(new OrderSpecifier<>(Order.DESC, game.releaseDate)); + orderSpecifiers.add(new OrderSpecifier<>(Order.ASC, ownedGame.game.id)); + } + + OrderSpecifier[] orderArray = orderSpecifiers.toArray(OrderSpecifier[]::new); + + pageIds = queryFactory + .select(ownedGame.game.id) + .from(ownedGame) + .join(ownedGame.game, game) + .where(where) + .orderBy(orderArray) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + if (pageIds.isEmpty()) { + return Page.empty(pageable); + } + + // 3) 기본 데이터 조회 (pageIds 순서 유지) + List baseRows = queryFactory + .select( + ownedGame.game.id, + ownedGame.game.gameName, + ownedGame.game.headerImage, + ownedGame.playtime2weeks, + ownedGame.playtimeForever, + game.likeCount, + game.avgRating + ) + .from(ownedGame) + .join(ownedGame.game, game) + .where(ownedGame.game.id.in(pageIds) + .and(ownedGame.user.userId.eq(userId)) + .and(ownedGame.isDeleted.isFalse())) + .fetch(); + + // pageIds 순서대로 정렬 + Map rowMap = baseRows.stream() + .collect(Collectors.toMap( + row -> row.get(ownedGame.game.id), + row -> row + )); + List orderedRows = pageIds.stream() + .map(rowMap::get) + .filter(Objects::nonNull) + .toList(); + + // 4) genres 조회 + Map> genresMap = fetchGenresByGameIds(pageIds); + + // 5) DTO 리스트 생성 (likeCount와 rating은 baseRows에서 직접 가져옴) + List content = orderedRows.stream() + .map(row -> { + Long gameId = row.get(ownedGame.game.id); + Float rating = row.get(game.avgRating); + Integer likeCount = row.get(game.likeCount); + + return OwnedGameListResponseDto.builder() + .gameId(gameId) + .gameName(row.get(ownedGame.game.gameName)) + .rating(rating != null ? rating : 0.0f) + .likeCount(likeCount != null ? likeCount : 0) + .playtime2weeks(row.get(ownedGame.playtime2weeks)) + .playtimeForever(row.get(ownedGame.playtimeForever)) + .headerImage(row.get(ownedGame.game.headerImage)) + .genres(genresMap.getOrDefault(gameId, List.of())) + .build(); + }) + .toList(); + + return new PageImpl<>(content, pageable, totalCount); + } + + private BooleanBuilder buildWhereClause(Integer userId, GameListRequestDto req, boolean applyFilter) { + BooleanBuilder where = new BooleanBuilder(); + + // 사용자 보유 게임 필터 + where.and(ownedGame.user.userId.eq(userId)); + where.and(ownedGame.isDeleted.isFalse()); + + // 게임 필터 + where.and(gameFilterPredicates.excludeGamesHavingAllThreeTags(applyFilter)); + + // 기본 필터 조건 + if (req.getQ() != null && !req.getQ().isBlank()) { + where.and(game.gameName.containsIgnoreCase(req.getQ())); + } + if (req.getMinPrice() != null) { + where.and(game.gameFinalPrice.goe(req.getMinPrice())); + } + if (req.getMaxPrice() != null) { + where.and(game.gameFinalPrice.loe(req.getMaxPrice())); + } + if (req.getDiscountFrom() != null) { + where.and(game.gameDiscount.goe(req.getDiscountFrom())); + } + if (req.getReleasedFrom() != null) { + where.and(game.releaseDate.goe(req.getReleasedFrom())); + } + if (req.getReleasedTo() != null) { + where.and(game.releaseDate.loe(req.getReleasedTo())); + } + + // OS 필터 + if (req.getPlatforms() != null && !req.getPlatforms().isEmpty()) { + BooleanBuilder os = new BooleanBuilder(); + for (String p : req.getPlatforms()) { + if (p == null) continue; + switch (p.toLowerCase()) { + case "windows" -> os.and(game.isWindow.isTrue()); + case "mac" -> os.and(game.isMac.isTrue()); + case "linux" -> os.and(game.isLinux.isTrue()); + } + } + if (os.hasValue()) { + where.and(os); + } + } + + // 언어 필터 + if (req.getLanguages() != null && !req.getLanguages().isEmpty()) { + List normalized = normalizeLanguages(req.getLanguages()); + where.and(existsLanguageIn(normalized)); + } + + // 속성 필터 + if (req.getGenres() != null && !req.getGenres().isEmpty()) { + where.and(existsAttributeIn(AttributeType.genre, req.getGenres())); + } + if (req.getTags() != null && !req.getTags().isEmpty()) { + where.and(existsAttributeIn(AttributeType.tag, req.getTags())); + } + if (req.getCategories() != null && !req.getCategories().isEmpty()) { + where.and(existsAttributeIn(AttributeType.category, req.getCategories())); + } + + // minRating 필터 + if (req.getMinRating() != null) { + where.and(game.avgRating.coalesce(0.0f).goe(req.getMinRating())); + } + + return where; + } + + /** + * 필터 조건을 기반으로 캐시 키용 해시를 생성합니다. + * + * @param userId 사용자 ID + * @param req 필터 요청 DTO + * @param applyFilter 태그 필터 적용 여부 + * @return 필터 조건의 해시값 + */ + private String generateFilterHash(Integer userId, GameListRequestDto req, boolean applyFilter) { + try { + StringBuilder sb = new StringBuilder(); + sb.append("library:"); + sb.append("userId=").append(userId).append(";"); + sb.append("applyFilter=").append(applyFilter).append(";"); + + if (req.getQ() != null) sb.append("q=").append(req.getQ()).append(";"); + if (req.getMinPrice() != null) sb.append("minPrice=").append(req.getMinPrice()).append(";"); + if (req.getMaxPrice() != null) sb.append("maxPrice=").append(req.getMaxPrice()).append(";"); + if (req.getMinRating() != null) sb.append("minRating=").append(req.getMinRating()).append(";"); + if (req.getDiscountFrom() != null) sb.append("discountFrom=").append(req.getDiscountFrom()).append(";"); + if (req.getReleasedFrom() != null) sb.append("releasedFrom=").append(req.getReleasedFrom()).append(";"); + if (req.getReleasedTo() != null) sb.append("releasedTo=").append(req.getReleasedTo()).append(";"); + + if (req.getPlatforms() != null && !req.getPlatforms().isEmpty()) { + sb.append("platforms=").append(String.join(",", req.getPlatforms().stream().sorted().toList())).append(";"); + } + if (req.getLanguages() != null && !req.getLanguages().isEmpty()) { + sb.append("languages=").append(String.join(",", req.getLanguages().stream().sorted().toList())).append(";"); + } + if (req.getGenres() != null && !req.getGenres().isEmpty()) { + sb.append("genres=").append(req.getGenres().stream().sorted().map(String::valueOf).collect(Collectors.joining(","))).append(";"); + } + if (req.getTags() != null && !req.getTags().isEmpty()) { + sb.append("tags=").append(req.getTags().stream().sorted().map(String::valueOf).collect(Collectors.joining(","))).append(";"); + } + if (req.getCategories() != null && !req.getCategories().isEmpty()) { + sb.append("categories=").append(req.getCategories().stream().sorted().map(String::valueOf).collect(Collectors.joining(","))).append(";"); + } + + // SHA-256 해시 생성 + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(sb.toString().getBytes(StandardCharsets.UTF_8)); + + // 16진수 문자열로 변환 (처음 16자만 사용) + StringBuilder hexString = new StringBuilder(); + for (int i = 0; i < 8; i++) { + String hex = Integer.toHexString(0xff & hash[i]); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (Exception e) { + // 실패 시 타임스탬프 기반 키 사용 (캐시 효과 없음) + return "fallback_" + System.currentTimeMillis(); + } + } + + private boolean isRatingSort(Sort sort) { + if (sort == null || sort.isUnsorted()) { + return false; + } + return sort.stream() + .anyMatch(o -> "rating".equalsIgnoreCase(o.getProperty())); + } + + private OrderSpecifier[] resolveOwnedGameOrder(Sort sort) { + if (sort == null || sort.isUnsorted()) { + return new OrderSpecifier[]{ + new OrderSpecifier<>(Order.DESC, ownedGame.playtimeForever) + }; + } + + return sort.stream() + .map(o -> { + Order order = o.isAscending() ? Order.ASC : Order.DESC; + String key = o.getProperty(); + if ("rating".equalsIgnoreCase(key)) { + return new OrderSpecifier<>(order, game.avgRating.coalesce(0.0f)); + } + ComparableExpressionBase expr = toOwnedGameExpr(key); + if (expr == null) return null; + return new OrderSpecifier<>(order, expr); + }) + .filter(Objects::nonNull) + .toArray(OrderSpecifier[]::new); + } + + private ComparableExpressionBase toOwnedGameExpr(String key) { + if (key == null) return null; + switch (key.toLowerCase()) { + case "price": + case "gamefinalprice": + return game.gameFinalPrice.coalesce(0); + case "releasedate": + return game.releaseDate; + case "like": + case "likecount": + return game.likeCount; + case "ownedplaytimeforever": + case "playtimeforever": + return ownedGame.playtimeForever.coalesce(0); + default: + return null; + } + } + + private Map> fetchGenresByGameIds(List gameIds) { + if (gameIds.isEmpty()) { + return Map.of(); + } + + List genreRows = queryFactory + .select(gameAttribute.id.gameId, attribute.name) + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.in(gameIds) + .and(attribute.type.eq(AttributeType.genre)) + ) + .fetch(); + + return genreRows.stream() + .collect(Collectors.groupingBy( + row -> row.get(gameAttribute.id.gameId), + Collectors.mapping( + row -> row.get(attribute.name), + Collectors.toList() + ) + )); + } + + private BooleanExpression existsAttributeIn(AttributeType type, List attributeIds) { + if (attributeIds == null || attributeIds.isEmpty()) return null; + + if (attributeIds.size() == 1) { + return JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(type)) + .and(attribute.id.eq(attributeIds.get(0))) + ) + .exists(); + } else { + BooleanExpression result = null; + for (Integer attributeId : attributeIds) { + BooleanExpression exists = JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(type)) + .and(attribute.id.eq(attributeId)) + ) + .exists(); + if (result == null) { + result = exists; + } else { + result = result.and(exists); + } + } + return result; + } + } + + private BooleanExpression existsLanguageIn(List languageNames) { + if (languageNames == null || languageNames.isEmpty()) return null; + + if (languageNames.size() == 1) { + return JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.language)) + .and(attribute.name.eq(languageNames.get(0))) + ) + .exists(); + } else { + BooleanExpression result = null; + for (String languageName : languageNames) { + BooleanExpression exists = JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.id.gameId.eq(game.id) + .and(attribute.type.eq(AttributeType.language)) + .and(attribute.name.eq(languageName)) + ) + .exists(); + if (result == null) { + result = exists; + } else { + result = result.and(exists); + } + } + return result; + } + } + + private List normalizeLanguages(List langs) { + if (langs == null) return List.of(); + List result = new ArrayList<>(); + for (String lang : langs) { + if (lang == null) continue; + String lower = lang.toLowerCase(); + + switch (lower) { + case "en", "영어" -> result.add("영어"); + case "ko", "한국어" -> result.add("한국어"); + case "ja", "일본어" -> result.add("일본어"); + case "zh-cn", "중국어 간체" -> result.add("중국어 간체"); + case "zh-tw", "중국어 번체" -> result.add("중국어 번체"); + default -> result.add(lang); + } + } + return result; + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/UserRepository.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/UserRepository.java new file mode 100644 index 0000000..88ea9b1 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/repository/UserRepository.java @@ -0,0 +1,25 @@ +package com.gg.application.user.repository; + +import com.gg.application.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findBySteamId(Long steamId); + + Optional findBySteamIdAndIsDeletedFalse(Long steamId); + + @Query("select u.tagFilterEnabled from User u where u.userId = :userId") + Optional findTagFilterEnabledByUserId(@Param("userId") Integer userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update User u set u.tagFilterEnabled = :enabled where u.userId = :userId") + int updateTagFilterEnabledByUserId(@Param("userId") Integer userId, @Param("enabled") boolean enabled); + + @Query("select u.avatarUrl from User u where u.userId = :userId") + String findAvatarUrlByUserId(@Param("userId") Integer userId); +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/service/LikeGameService.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/service/LikeGameService.java new file mode 100644 index 0000000..c600624 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/service/LikeGameService.java @@ -0,0 +1,97 @@ +package com.gg.application.user.service; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.user.dto.response.LikeGameResponseDto; +import com.gg.application.game.repository.GameRepository; +import com.gg.application.game.service.GameCacheService; +import com.gg.application.user.repository.UserRepository; +import com.gg.application.user.repository.LikeGameRepository; +import com.gg.config.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class LikeGameService { + + private final LikeGameRepository likeGameRepository; + private final GameRepository gameRepository; + private final UserRepository userRepository; + private final GameCacheService gameCacheService; + + /** + * 사용자가 위시 리스트에 추가한 게임들의 정보 (필터링 적용) + * + * @param userDetails 사용자 정보 + * @param req 필터 조건 + * @return 게임 정보를 담은 리스트 + */ + @Transactional(readOnly = true) + public List getLikeGames(CustomUserDetails userDetails, GameListRequestDto req) { + // 검색어 길이 검증 + if (req != null && req.getQ() != null && req.getQ().length() > 50) { + throw new IllegalArgumentException("검색어는 50자 이하여야 합니다."); + } + + Integer userId = userDetails.getUserId(); + + // 필터 적용 여부 확인 + boolean applyFilter = shouldApplyTagFilter(userDetails); + + return likeGameRepository.searchLikeGames(userId, req, applyFilter); + } + + private boolean shouldApplyTagFilter(CustomUserDetails user) { + if (user == null) return true; + Boolean enabled = gameCacheService.getTagFilterEnabled(user.getUserId()); + return enabled != null ? enabled : true; + } + + /** + * 사용자가 위시 리스트에서 게임 제거 + * + * @param userId 유저 아이디 + * @param gameId 게임 아이디 + * + */ + public void deleteLikeGames(Integer userId, Long gameId) { + // 존재 여부만 확인 (엔티티 전체 조회 불필요) + if (!userRepository.existsById(userId)) { + throw new RuntimeException("존재하지 않는 유저입니다."); + } + if (!gameRepository.existsById(gameId)) { + throw new RuntimeException("존재하지 않는 게임입니다."); + } + + // 단일 쿼리로 like_games 삭제와 like_count 감소를 원자적으로 처리 + likeGameRepository.deleteByIds(userId, gameId); + } + + /** + * 사용자가 위시 리스트에 게임 추가 + * + * @param userId 유저 아이디 + * @param gameId 게임 아이디 + * + */ + public void addLikeGames(Integer userId, Long gameId) { + // 존재 여부만 확인 (엔티티 전체 조회 불필요) + if (!userRepository.existsById(userId)) { + throw new RuntimeException("존재하지 않는 유저입니다."); + } + if (!gameRepository.existsById(gameId)) { + throw new RuntimeException("존재하지 않는 게임입니다."); + } + + log.debug("userid : {}, gameId : {}", userId, gameId); + + // 단일 쿼리로 like_games 추가와 like_count 증가를 원자적으로 처리 + likeGameRepository.insertIfNotExists(userId, gameId); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/service/OwnedGameService.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/service/OwnedGameService.java new file mode 100644 index 0000000..58528e7 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/service/OwnedGameService.java @@ -0,0 +1,280 @@ +package com.gg.application.user.service; + +import com.gg.application.game.dto.request.GameListRequestDto; +import com.gg.application.game.dto.response.GameIdNamePair; +import com.gg.application.game.dto.response.PageResponse; +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.entity.Game; +import com.gg.application.game.repository.GameRepository; +import com.gg.application.user.dto.request.OwnedGameListInfoDto; +import com.gg.application.user.dto.request.OwnedGameRequestDto; +import com.gg.application.user.dto.response.OwnedGameListResponseDto; +import com.gg.application.user.repository.OwnedGameRepository; +import com.gg.application.user.repository.UserRepository; +import com.gg.application.game.service.GameCacheService; +import com.gg.common.steam.SteamApiClient; +import com.gg.config.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class OwnedGameService { + + private final UserRepository userRepository; + private final GameRepository gameRepository; + private final OwnedGameRepository ownedGameRepository; + private final SteamApiClient steamApiClient; + private final GameCacheService gameCacheService; + + @Transactional(readOnly = true) + public PageResponse getOwnedGames( + CustomUserDetails userDetails, + GameListRequestDto req, + int page, + int size, + String sort, + String order) { + // 검색어 길이 검증 + if (req != null && req.getQ() != null && req.getQ().length() > 50) { + throw new IllegalArgumentException("검색어는 50자 이하여야 합니다."); + } + + Integer userId = userDetails.getUserId(); + int p = page < 0 ? 0 : page; + int s = size < 1 ? 20 : Math.min(size, 100); + + // 정렬 처리 + Sort resolvedSort = resolveOwnedGameSort(sort, order); + Pageable pageable = PageRequest.of(p, s, resolvedSort); + + // 필터 적용 여부 확인 + boolean applyFilter = shouldApplyTagFilter(userDetails); + + Page result = ownedGameRepository.searchOwnedGames(userId, req, pageable, applyFilter); + String sortKey = normalizeSortKey(sort); + String sortOrder = normalizeOrder(order); + + return PageResponse.of(result, sortKey, sortOrder); + } + + private boolean shouldApplyTagFilter(CustomUserDetails user) { + if (user == null) return true; + Boolean enabled = gameCacheService.getTagFilterEnabled(user.getUserId()); + return enabled != null ? enabled : true; + } + + private Sort resolveOwnedGameSort(String sortKey, String order) { + String normalizedKey = normalizeSortKey(sortKey); + Sort.Direction dir = parseDirection(order); + + switch (normalizedKey.toLowerCase()) { + case "price": + case "gamefinalprice": + return Sort.by(dir, "gameFinalPrice"); + case "releasedate": + return Sort.by(dir, "releaseDate"); + case "like": + case "likecount": + return Sort.by(dir, "likeCount"); + case "rating": + return Sort.by(dir, "rating"); + case "ownedplaytimeforever": + return Sort.by(dir, "playtimeForever"); + default: + return Sort.by(Sort.Direction.DESC, "playtimeForever"); + } + } + + private String normalizeSortKey(String key) { + if (key == null || key.isBlank()) return "ownedPlaytimeForever"; + return key; + } + + private String normalizeOrder(String order) { + if (order == null || order.isBlank()) return "desc"; + return order.toLowerCase(); + } + + private Sort.Direction parseDirection(String order) { + String normalized = normalizeOrder(order); + return "asc".equals(normalized) ? Sort.Direction.ASC : Sort.Direction.DESC; + } + + @Transactional(readOnly = true) + public List getOwnedGamesDeveloper(Integer userId) { + + List cores = ownedGameRepository.findDtoByUserIdAndGameId(userId); + if (cores.isEmpty()) return List.of(); + + List gameIds = cores.stream().map(game -> game.getGameId()).toList(); + List pairs = gameRepository.findAttributeNamebyGameIdAndType(gameIds, AttributeType.genre); + Map> genresMap = pairs.stream() + .collect(Collectors.groupingBy( + GameIdNamePair::getGameId, + Collectors.mapping(GameIdNamePair::getName, Collectors.toList()) + )); + + return cores.stream() + .map(c -> OwnedGameListResponseDto.builder() + .gameId(c.getGameId()) + .gameName(c.getGameName()) + .rating(c.getRating()) + .likeCount(c.getLikeCount()) + .playtime2weeks(c.getPlaytime2weeks()) + .playtimeForever(c.getPlaytimeForever()) + .headerImage(c.getHeaderImage()) + .genres(genresMap.getOrDefault(c.getGameId(), List.of())) + .build()) + .toList(); + } + + @Async("steamSyncExecutor") + public void syncOwnedGames(CustomUserDetails userDetails) { + try { + Integer userId = userDetails.getUserId(); + + // 사용자 steamId 확인 (없으면 중단) + var user = userRepository.findBySteamId(userDetails.getSteamId()) + .orElseThrow(() -> new IllegalArgumentException("Steam Id not found")); + Long steamId = user.getSteamId(); // Long 사용 (SteamApiClient 시그니처에 맞춤) + + // 1) Steam API → 요청 DTO 리스트 + List list = steamApiClient.getOwnedGames(userId, steamId); + log.info("Steam API에서 가져온 게임 수: userId={}, steamId={}, count={}", userId, steamId, list.size()); + if (list.isEmpty()) return; + + // 2) games 존재하는 appId만 허용 (사전 로딩) + Set appIds = list.stream() + .map(OwnedGameRequestDto::getGameId) + .collect(Collectors.toSet()); + + // Redis 캐시에서 조회 + Boolean cached = gameCacheService.getTagFilterEnabled(userId); + Boolean enabled = cached != null ? cached : true; + + Set allowedAppIds = gameRepository.findAllowedAppIdsExcludingThreeTags(appIds, enabled); + log.info("DB에 존재하는 게임 수: userId={}, total={}, allowed={}", userId, appIds.size(), allowedAppIds.size()); + if (allowedAppIds.isEmpty()) return; + + Map gameMap = gameRepository.findAllById(allowedAppIds).stream() + .collect(Collectors.toMap(Game::getId, g -> g)); + + // 3) 기존 owned_games 미리 조회 (존재 여부만) + Set existingIds = ownedGameRepository + .findByIdUserIdAndIdGameIdIn(userId, allowedAppIds).stream() + .map(og -> og.getId().getGameId()) + .collect(Collectors.toSet()); + + // 4) 배치 처리: INSERT와 UPDATE를 분리하여 배치 크기로 나눠 처리 + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + + for (OwnedGameRequestDto dto : list) { + if (!gameMap.containsKey(dto.getGameId())) continue; // games에 없으면 무시 + + if (existingIds.contains(dto.getGameId())) { + toUpdate.add(dto); + } else { + toInsert.add(dto); + } + } + + // 배치 크기로 나눠서 INSERT 처리 (배치 크기: 100) + int batchSize = 100; + log.info("동기화 시작: userId={}, INSERT={}, UPDATE={}", userId, toInsert.size(), toUpdate.size()); + for (int i = 0; i < toInsert.size(); i += batchSize) { + int end = Math.min(i + batchSize, toInsert.size()); + List batch = toInsert.subList(i, end); + for (OwnedGameRequestDto dto : batch) { + ownedGameRepository.insertFromDto(dto); + } + } + + // 배치 크기로 나눠서 UPDATE 처리 (배치 크기: 100) + for (int i = 0; i < toUpdate.size(); i += batchSize) { + int end = Math.min(i + batchSize, toUpdate.size()); + List batch = toUpdate.subList(i, end); + for (OwnedGameRequestDto dto : batch) { + ownedGameRepository.updateFromDto(dto); + } + } + log.info("동기화 완료: userId={}, INSERT={}, UPDATE={}", userId, toInsert.size(), toUpdate.size()); + } catch (Exception e) { + log.error("Async Steam sync failed. userId={}", userDetails.getUserId(), e); + } + } + + public void syncOwnedGamesDevelopers(Long steamId, Integer userId) { + + // 1) Steam API → 요청 DTO 리스트 + List list = steamApiClient.getOwnedGames(userId, steamId); + if (list.isEmpty()) return; + + // games 존재하는 appId만 허용 + var appIds = list.stream().map(OwnedGameRequestDto::getGameId).collect(Collectors.toSet()); + + // Redis 캐시에서 조회 + Boolean cached = gameCacheService.getTagFilterEnabled(userId); + boolean enabled = cached != null ? cached : true; + + Set allowedAppIds = gameRepository.findAllowedAppIdsExcludingThreeTags(appIds, enabled); + if (allowedAppIds.isEmpty()) return; + + var gameMap = gameRepository.findAllById(allowedAppIds).stream() + .collect(Collectors.toMap(Game::getId, g -> g)); + + // 기존 owned_games 미리 조회 + var existingMap = ownedGameRepository.findByIdUserIdAndIdGameIdIn(userId, allowedAppIds).stream() + .collect(Collectors.toMap(og -> og.getId().getGameId(), og -> Boolean.TRUE)); + + // 배치 처리: INSERT와 UPDATE를 분리하여 배치 크기로 나눠 처리 + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + + for (var dto : list) { + if (!gameMap.containsKey(dto.getGameId())) continue; // games에 없으면 무시 + + if (existingMap.containsKey(dto.getGameId())) { + toUpdate.add(dto); + } else { + toInsert.add(dto); + } + } + + // 배치 크기로 나눠서 INSERT 처리 (배치 크기: 100) + int batchSize = 100; + for (int i = 0; i < toInsert.size(); i += batchSize) { + int end = Math.min(i + batchSize, toInsert.size()); + List batch = toInsert.subList(i, end); + for (OwnedGameRequestDto dto : batch) { + ownedGameRepository.insertFromDto(dto); + } + } + + // 배치 크기로 나눠서 UPDATE 처리 (배치 크기: 100) + for (int i = 0; i < toUpdate.size(); i += batchSize) { + int end = Math.min(i + batchSize, toUpdate.size()); + List batch = toUpdate.subList(i, end); + for (OwnedGameRequestDto dto : batch) { + ownedGameRepository.updateFromDto(dto); + } + } + } + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/application/user/service/UserService.java b/gg_web/gg_backend/src/main/java/com/gg/application/user/service/UserService.java new file mode 100644 index 0000000..e6c35a9 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/application/user/service/UserService.java @@ -0,0 +1,234 @@ +package com.gg.application.user.service; + +import com.gg.application.review.repository.ReviewHelpfulRepository; +import com.gg.application.review.repository.ReviewRepository; +import com.gg.application.user.dto.response.UserAvatarResponseDto; +import com.gg.application.user.dto.response.UserSummaryResponseDto; +import com.gg.application.user.dto.response.UserTagFilterResponseDto; +import com.gg.application.user.entity.User; +import com.gg.application.user.repository.OwnedGameRepository; +import com.gg.application.user.repository.UserRepository; +import com.gg.application.game.service.GameCacheService; +import com.gg.config.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class UserService { + + private final UserRepository userRepository; + private final ReviewRepository reviewRepository; + private final ReviewHelpfulRepository reviewHelpfulRepository; + private final OwnedGameRepository ownedGameRepository; + private final GameCacheService gameCacheService; + + @Value("${steam.api.key}") + private String steamApiKey; + + private static final String PLAYER_SUMMARIES_BASE_URL = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/"; + + /** + * 유저가 존재하면 반환 / 삭제된 계정이면 복구 / 없으면 Steam API로 이름 가져와 신규 생성 후 저장 + */ + @Transactional + public User findOrCreateBySteamId(Long steamId) throws IOException, ParseException { + String[] userDetails = getUserNameAndAvatarFromSteam(steamId); + String name = userDetails[0]; + String avatarUrl = userDetails[1]; + + Optional existing = userRepository.findBySteamId(steamId); + if (existing.isPresent()) { + User user = existing.get(); + + if (user.isDeleted()) { + // 삭제된 계정이면 복구 + 정보 갱신 + user.reactivate(name, avatarUrl); + } else { + // 활성 계정이면 닉네임과 아바타 갱신 + user.updateUserName(name); + user.updateAvatarUrl(avatarUrl); + } + return userRepository.save(user); + } + + // 신규 생성 + return userRepository.save( + User.builder() + .steamId(steamId) + .userName(userDetails[0]) + .avatarUrl(userDetails[1]) + .tag(true) + .build() + ); + } + + /** + * Steam API에서 유저 이름 가져오기 + */ + @Transactional(readOnly = true) + public String[] getUserNameAndAvatarFromSteam(Long steamId) throws IOException, ParseException { + String urlStr = PLAYER_SUMMARIES_BASE_URL + + "?key=" + steamApiKey + + "&steamids=" + steamId; + + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-type", "application/json"); + + boolean ok = conn.getResponseCode() >= 200 && conn.getResponseCode() <= 299; + String json = read(conn, ok); + conn.disconnect(); + + JSONObject response = (JSONObject) ((JSONObject) new JSONParser().parse(json)).get("response"); + JSONArray players = (JSONArray) response.get("players"); + + if (players == null || players.isEmpty()) { + throw new IllegalStateException("Steam user not found: " + steamId); + } + + JSONObject player = (JSONObject) players.get(0); + String userName = String.valueOf(player.get("personaname")); + String avatarUrl = String.valueOf(player.get("avatarfull")); // 아바타 URL + + return new String[]{userName, avatarUrl}; + } + + private static String read(HttpURLConnection conn, boolean ok) throws IOException { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(ok ? conn.getInputStream() : conn.getErrorStream()))) { + StringBuilder sb = new StringBuilder(); + String line; while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + @Transactional + public void deleteUser(Integer userId) { + var user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("user not found: " + userId)); + + if (user.isDeleted()) { + return; // 이미 삭제 처리된 경우 멱등성 유지 + } + + user.softDelete(); + } + + @Transactional(readOnly = true) + public UserSummaryResponseDto getSummary(Integer userId) { + long reviewCount = reviewRepository.countByUser_UserId(userId); + long totalHelpful = reviewHelpfulRepository.countAllHelpfulReceivedByUser(userId); + + // 보유 장르 비율 + List genreRows = ownedGameRepository.findOwnedGenreCounts(userId); + long total = genreRows.stream() + .mapToLong(r -> ((Number) r[1]).longValue()) + .sum(); + + List ratios = genreRows.stream() + .map(r -> { + String genre = (String) r[0]; + long cnt = ((Number) r[1]).longValue(); // 장르에 해당하는 게임 수 + + // 장르별 총 플레이타임 + long totalPlaytimeInMinutes = ((Number) r[2]).longValue(); + Float totalPlaytime = Math.round((totalPlaytimeInMinutes / 60.0f) * 10.0f) / 10.0f; + + // 최근 2주간 플레이한 장르별 플레이타임 + long recentPlaytimeInMinutes = ((Number) r[3]).longValue(); + Float recentPlaytime = Math.round((recentPlaytimeInMinutes / 60.0f) * 10.0f) / 10.0f; + + double ratio = (total == 0) ? 0.0 : Math.round((cnt * 1000.0 / total)) / 10.0; + return UserSummaryResponseDto.GenreRatio.builder() + .genre(genre) + .ratio(ratio) + .totalPlaytime(totalPlaytime) + .recentPlaytime(recentPlaytime) + .build(); + }) + .toList(); + + return UserSummaryResponseDto.builder() + .reviewCount(reviewCount) + .totalHelpfulCount(totalHelpful) + .ownedGenreRatio(ratios) + .build(); + } + + @Transactional(readOnly = true) + public UserTagFilterResponseDto getUserTagFilter(CustomUserDetails user) { + if (user == null) { + return new UserTagFilterResponseDto(true); + } + // Redis 캐시에서 조회 + Boolean enabled = gameCacheService.getTagFilterEnabled(user.getUserId()); + return UserTagFilterResponseDto.builder().enabled(enabled != null ? enabled : true).build(); + } + + public UserTagFilterResponseDto updateUserTagFilter(CustomUserDetails user, boolean enabled) { + // DB 업데이트 + userRepository.updateTagFilterEnabledByUserId(user.getUserId(), enabled); + + // 즉시 캐시 업데이트 (응답 전에 완료) - 빠른 작업만 수행 + gameCacheService.invalidateTagFilterEnabled(user.getUserId()); + gameCacheService.setTagFilterEnabled(user.getUserId(), enabled); + + // count 캐시 무효화는 완전히 비동기로 처리하여 응답 지연 방지 + // 트랜잭션 커밋 후 별도 스레드에서 실행 + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + // CompletableFuture를 사용하여 별도 스레드에서 실행 + CompletableFuture.runAsync(() -> { + try { + gameCacheService.invalidateAllCountCache(); + } catch (Exception e) { + // 캐시 무효화 실패해도 응답에는 영향 없음 + log.warn("count 캐시 무효화 실패 (비동기): userId={}", user.getUserId(), e); + } + }); + } + }); + } else { + // 트랜잭션이 없으면 즉시 비동기 실행 + CompletableFuture.runAsync(() -> { + try { + gameCacheService.invalidateAllCountCache(); + } catch (Exception e) { + log.warn("count 캐시 무효화 실패 (비동기): userId={}", user.getUserId(), e); + } + }); + } + + // 응답 즉시 반환 (캐시 무효화는 백그라운드에서 처리) + return UserTagFilterResponseDto.builder().enabled(enabled).build(); + } + + @Transactional(readOnly = true) + public UserAvatarResponseDto getAvatar(Integer userId) { + String avatarUrl = userRepository.findAvatarUrlByUserId(userId); + return new UserAvatarResponseDto(avatarUrl); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/common/exception/NotFoundException.java b/gg_web/gg_backend/src/main/java/com/gg/common/exception/NotFoundException.java new file mode 100644 index 0000000..d9d2750 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/common/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.gg.common.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/common/jwt/JwtProvider.java b/gg_web/gg_backend/src/main/java/com/gg/common/jwt/JwtProvider.java new file mode 100644 index 0000000..f5acc70 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/common/jwt/JwtProvider.java @@ -0,0 +1,104 @@ +package com.gg.common.jwt; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; + +@Component +public class JwtProvider { + + @Value("${spring.jwt.access-token-expiration-ms}") + private long ACCESS_TOKEN_EXPIRE_SECONDS; + + @Value("${spring.jwt.refresh-token-expiration-ms}") + private long REFRESH_TOKEN_EXPIRE_SECONDS; + + @Value("${spring.jwt.secret}") + private String SECRET_KEY; + + private SecretKey key; + + @PostConstruct + public void generateKey() { + this.key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(JwtUserDto user) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_SECONDS); + + return Jwts.builder() + .setSubject(String.valueOf(user.getId())) + .claim("steamId", user.getSteamId()) + .claim("userName", user.getUserName()) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String generateRefreshToken(JwtUserDto user) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_SECONDS); + + return Jwts.builder() + .setSubject(String.valueOf(user.getId())) + .claim("steamId", user.getSteamId()) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.ExpiredJwtException e) { + return false; // 만료 + } catch (JwtException | IllegalArgumentException e) { + return false; // 서명오류/변조/형식오류 등 + } + } + + public Long getSteamIdFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .get("steamId", Long.class); + } + + + public Integer getUserIdFromToken(String token) { + return Integer.parseInt( + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody().getSubject()); + } + + public String getUserNameFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .get("userName", String.class); + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/common/jwt/JwtUserDto.java b/gg_web/gg_backend/src/main/java/com/gg/common/jwt/JwtUserDto.java new file mode 100644 index 0000000..a607c0c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/common/jwt/JwtUserDto.java @@ -0,0 +1,16 @@ +package com.gg.common.jwt; + +import lombok.Getter; + +@Getter +public class JwtUserDto { + private Integer id; + private Long steamId; + private String userName; + + public JwtUserDto(Integer id, Long steamId, String userName) { + this.id = id; + this.steamId = steamId; + this.userName = userName; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/common/query/GameFilterPredicates.java b/gg_web/gg_backend/src/main/java/com/gg/common/query/GameFilterPredicates.java new file mode 100644 index 0000000..059cf30 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/common/query/GameFilterPredicates.java @@ -0,0 +1,139 @@ +package com.gg.common.query; + +import com.gg.application.game.entity.AttributeType; +import com.gg.application.game.entity.QGame; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.JPAExpressions; +import org.springframework.stereotype.Component; + +import static com.gg.application.game.entity.QGame.game; +import static com.gg.application.game.entity.QGameAttribute.gameAttribute; +import static com.gg.application.game.entity.QAttribute.attribute; + +@Component +public class GameFilterPredicates { + + // 128661, 128634, 128708 모두 가진 게임을 제외 + // 최적화: 상관 서브쿼리를 IN 서브쿼리로 변경하여 성능 개선 + public BooleanExpression excludeGamesHavingAllThreeTags(Boolean applyFilter) { + if (Boolean.FALSE.equals(applyFilter)) return null; + + // IN 서브쿼리를 사용하여 성능 최적화 + // 상관 서브쿼리 대신 비상관 서브쿼리로 변경하여 한 번만 실행 + BooleanExpression excludeTwoTags = game.id.in( + JPAExpressions + .select(gameAttribute.game.id) + .from(gameAttribute) + .where(gameAttribute.attribute.id.in(128661, 128634, 128708)) + .groupBy(gameAttribute.game.id) + .having(gameAttribute.attribute.id.countDistinct().eq(2L)) + ); + + BooleanExpression excludeDevelopers = excludeDeveloperIds(); + BooleanExpression combined = excludeTwoTags.or(badNamePredicate(game)); + if (excludeDevelopers != null) { + combined = combined.or(excludeDevelopers); + } + return combined.not(); + } + + /** 외부 FROM이 Review 등이고, 게임 id 경로만 있을 때 사용 */ + public BooleanExpression excludeGamesHavingAllThreeTagsOn(NumberExpression outerGameId, Boolean applyFilter) { + if (Boolean.FALSE.equals(applyFilter)) return null; + + BooleanExpression excludeTwoTags = JPAExpressions + .selectOne() + .from(gameAttribute) + .where( + gameAttribute.game.id.eq(outerGameId) // ★ r.game.id와 상관 + .and(gameAttribute.attribute.id.in(128661L, 128634L, 128708L)) + ) + .groupBy(gameAttribute.game.id) + .having(gameAttribute.attribute.id.countDistinct().eq(2L)) + .exists(); + + BooleanExpression badNameExists = JPAExpressions + .selectOne() + .from(game) + .where(game.id.eq(outerGameId).and(badNamePredicate(game))) + .exists(); + + BooleanExpression combined = excludeTwoTags.or(badNameExists); + + Integer[] excludedIds = getExcludedDeveloperIds(); + if (excludedIds != null && excludedIds.length > 0) { + BooleanExpression excludeDevelopers = JPAExpressions + .selectOne() + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + gameAttribute.game.id.eq(outerGameId) + .and(attribute.type.eq(AttributeType.developer)) + .and(attribute.id.in(excludedIds)) + ) + .exists(); + combined = combined.or(excludeDevelopers); + } + + return combined.not(); + } + + /** 🔹 금칙어 조건 생성 공용 메서드 */ + private BooleanExpression badNamePredicate(QGame g) { + return g.gameName.containsIgnoreCase("sex") + .or(g.gameName.containsIgnoreCase("hentai")) + .or(g.gameName.containsIgnoreCase("ero")) + .or(g.gameName.containsIgnoreCase("girl")) + .or(g.gameName.containsIgnoreCase("pretty")) + .or(g.gameName.containsIgnoreCase("dominatrixes")) + .or(g.gameName.containsIgnoreCase("live2d")) + .or(g.gameName.containsIgnoreCase("playtest")) + .or(g.gameName.containsIgnoreCase("av")) + .or(g.gameName.containsIgnoreCase("fucker")) + .or(g.gameName.containsIgnoreCase("헨타이")) + .or(g.gameName.containsIgnoreCase("섹스")); + } + + /** 🔹 제외할 developer attribute_id 목록 (직접 추가 가능) */ + private Integer[] getExcludedDeveloperIds() { + // 여기에 제외할 developer attribute_id를 추가하세요 + return new Integer[]{ + // 예시: 12345, 67890 + 104, 189, 1197, 1380, 1845, 1921, 2351, 2905, 3562, 5090, 5603, 5695, + 6035, 6708, 6728, 6964, 7047, 7173, 7371, 8594, 8781, 8950, 9675, 9864, + 9983, 10153, 11254, 11273, 11764, 12340, 12672, 13366, 13562, 13641, + 13845, 14130, 14339, 14730, 14956, 16267, 16618, 16743, 17178, 17288, + 17555, 18897, 19267, 19615, 19705, 20019, 20609, 20862, 22342, 22406, + 22451, 22715, 23617, 26803, 27265, 29035, 29246, 29679, 29860, 30046, + 30202, 30420, 31217, 31423, 31516, 32031, 33444, 34430, 35633, 35787, + 36372, 36745, 37481, 39084, 39828, 40059, 42321, 42363, 42882, 43395, + 43508, 43705, 44428, 45016, 45202, 45253, 45393, 46084, 46235, 46323, + 47342, 47943, 48041, 49017, 49087, 49819, 50239, 50765, 51104, 51305, + 51570, 51708, 51710, 51790, 52018, 52050, 52176, 52448, 54289, 54850, + 56561, 57272, 57560, 58927, 58976, 59537, 60433, 60601, 60853, 61201, + 61690, 61751, 61994, 62501, 62886, 64630, 64679, 64777, 64887, 65041, + 65071, 66316, 67485, 68115, 68897, 68935, 69099, 69433, 70075, 78674, + 147304, 8613, 21557, 615, 12359, 28006, 12482, 45759, 63780, 49145 + }; + } + + /** 🔹 developer attribute_id 필터링 조건 생성 */ + private BooleanExpression excludeDeveloperIds() { + Integer[] excludedIds = getExcludedDeveloperIds(); + if (excludedIds == null || excludedIds.length == 0) { + return null; + } + + return game.id.in( + JPAExpressions + .select(gameAttribute.game.id) + .from(gameAttribute) + .join(gameAttribute.attribute, attribute) + .where( + attribute.type.eq(AttributeType.developer) + .and(attribute.id.in(excludedIds)) + ) + ); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/common/steam/SteamApiClient.java b/gg_web/gg_backend/src/main/java/com/gg/common/steam/SteamApiClient.java new file mode 100644 index 0000000..188cd5f --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/common/steam/SteamApiClient.java @@ -0,0 +1,94 @@ +package com.gg.common.steam; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gg.application.user.dto.request.OwnedGameRequestDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class SteamApiClient { + + @Value("${steam.api.key}") + private String steamApiKey; + + private static final String BASE_URL = "https://api.steampowered.com"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private RestTemplate restTemplate() { + var f = new SimpleClientHttpRequestFactory(); + f.setConnectTimeout((int) Duration.ofSeconds(5).toMillis()); + f.setReadTimeout((int) Duration.ofSeconds(10).toMillis()); + return new RestTemplate(f); + } + + public List getOwnedGames(Integer userId, Long steamId) { + String url = UriComponentsBuilder + .fromHttpUrl(BASE_URL) + .path("/IPlayerService/GetOwnedGames/v0001/") + .queryParam("key", steamApiKey) + .queryParam("steamid", steamId) + .queryParam("format", "json") + .queryParam("include_played_free_games", 1) + .build(true) + .toUriString(); + + var rt = restTemplate(); + String raw = rt.getForObject(url, String.class); + if (raw == null) { + return Collections.emptyList(); + } + + try { + JsonNode root = objectMapper.readTree(raw); + JsonNode responseNode = root.path("response"); + + // game_count 확인 (Steam API 응답에 포함됨) + int gameCount = responseNode.has("game_count") ? responseNode.get("game_count").asInt() : -1; + + JsonNode gamesNode = responseNode.path("games"); + if (gamesNode.isMissingNode() || !gamesNode.isArray()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + for (JsonNode g : gamesNode) { + Long appid = g.path("appid").asLong(); + int playtime2weeks = g.has("playtime_2weeks") ? g.get("playtime_2weeks").asInt() : 0; + int playtimeForever = g.has("playtime_forever") ? g.get("playtime_forever").asInt() : 0; + + OwnedGameRequestDto dto = OwnedGameRequestDto.builder() + .userId(userId) + .gameId(appid) + .playtime2weeks(playtime2weeks) + .playtimeForever(playtimeForever) + .build(); + + result.add(dto); + } + + // 경고: game_count와 실제 반환된 게임 수가 다를 경우 + if (gameCount > 0 && result.size() != gameCount) { + System.err.println(String.format( + "[Steam API Warning] userId=%d, steamId=%d: game_count=%d, 실제 반환된 게임 수=%d. Steam API가 일부 게임만 반환했을 수 있습니다.", + userId, steamId, gameCount, result.size() + )); + } + + return result; + } catch (Exception e) { + throw new RuntimeException("Steam API 응답 파싱 실패", e); + } + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/AsyncConfig.java b/gg_web/gg_backend/src/main/java/com/gg/config/AsyncConfig.java new file mode 100644 index 0000000..cf3c5e0 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/AsyncConfig.java @@ -0,0 +1,35 @@ +package com.gg.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "steamSyncExecutor") + public Executor steamSyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + // 기본 스레드 개수 (항상 유지되는 최소 스레드 수) + executor.setCorePoolSize(4); + + // 동시에 처리할 수 있는 최대 스레드 개수 + executor.setMaxPoolSize(8); + + // 대기열에 쌓을 수 있는 작업 개수 + executor.setQueueCapacity(200); + + // 생성되는 스레드 이름 접두사 + executor.setThreadNamePrefix("SteamSync-"); + + // 스레드풀 초기화 + executor.initialize(); + + return executor; + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/CaffeineCacheConfig.java b/gg_web/gg_backend/src/main/java/com/gg/config/CaffeineCacheConfig.java new file mode 100644 index 0000000..056269c --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/CaffeineCacheConfig.java @@ -0,0 +1,118 @@ +package com.gg.config; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.gg.application.game.dto.response.AttributeResponseDto; +import com.gg.application.game.dto.response.GameDetailResponseDto; +import com.gg.application.game.dto.response.GameMediaResponseDto; +import com.gg.application.game.entity.AttributeType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Caffeine 인메모리 캐시 설정 + * 1차 캐시로 사용되며, Redis(2차 캐시)보다 빠른 조회를 제공합니다. + */ +@Configuration +public class CaffeineCacheConfig { + + // Tag Filter 캐시 설정 + @Value("${cache.caffeine.tag-filter.max-size:10000}") + private long tagFilterMaxSize; + + @Value("${cache.caffeine.tag-filter.expire-after-write-minutes:30}") + private long tagFilterExpireMinutes; + + // Game Detail 캐시 설정 + @Value("${cache.caffeine.game-detail.max-size:1000}") + private long gameDetailMaxSize; + + @Value("${cache.caffeine.game-detail.expire-after-write-hours:1}") + private long gameDetailExpireHours; + + // Game Media 캐시 설정 + @Value("${cache.caffeine.game-media.max-size:1000}") + private long gameMediaMaxSize; + + @Value("${cache.caffeine.game-media.expire-after-write-hours:2}") + private long gameMediaExpireHours; + + // Attributes 캐시 설정 + @Value("${cache.caffeine.attributes.max-size:10}") + private long attributesMaxSize; + + @Value("${cache.caffeine.attributes.expire-after-write-hours:12}") + private long attributesExpireHours; + + // Game Count 캐시 설정 + @Value("${cache.caffeine.game-count.max-size:1000}") + private long gameCountMaxSize; + + @Value("${cache.caffeine.game-count.expire-after-write-minutes:30}") + private long gameCountExpireMinutes; + + /** + * 사용자 tag_filter_enabled 설정용 인메모리 캐시 + */ + @Bean("tagFilterCache") + public Cache tagFilterCache() { + return Caffeine.newBuilder() + .maximumSize(tagFilterMaxSize) + .expireAfterWrite(tagFilterExpireMinutes, TimeUnit.MINUTES) + .recordStats() + .build(); + } + + /** + * 게임 상세 정보용 인메모리 캐시 + */ + @Bean("gameDetailCache") + public Cache gameDetailCache() { + return Caffeine.newBuilder() + .maximumSize(gameDetailMaxSize) + .expireAfterWrite(gameDetailExpireHours, TimeUnit.HOURS) + .recordStats() + .build(); + } + + /** + * 게임 미디어 정보용 인메모리 캐시 + */ + @Bean("gameMediaCache") + public Cache gameMediaCache() { + return Caffeine.newBuilder() + .maximumSize(gameMediaMaxSize) + .expireAfterWrite(gameMediaExpireHours, TimeUnit.HOURS) + .recordStats() + .build(); + } + + /** + * 속성 목록용 인메모리 캐시 + */ + @Bean("attributesCache") + public Cache> attributesCache() { + return Caffeine.newBuilder() + .maximumSize(attributesMaxSize) + .expireAfterWrite(attributesExpireHours, TimeUnit.HOURS) + .recordStats() + .build(); + } + + /** + * 게임 목록 count용 인메모리 캐시 + */ + @Bean("gameCountCache") + public Cache gameCountCache() { + return Caffeine.newBuilder() + .maximumSize(gameCountMaxSize) + .expireAfterWrite(gameCountExpireMinutes, TimeUnit.MINUTES) + .recordStats() + .build(); + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/QuerydslConfig.java b/gg_web/gg_backend/src/main/java/com/gg/config/QuerydslConfig.java new file mode 100644 index 0000000..e590ad1 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/QuerydslConfig.java @@ -0,0 +1,15 @@ +package com.gg.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @Bean + public JPAQueryFactory queryFactory(EntityManager em) { + return new JPAQueryFactory(em); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/RedisConfig.java b/gg_web/gg_backend/src/main/java/com/gg/config/RedisConfig.java new file mode 100644 index 0000000..2a4e700 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/RedisConfig.java @@ -0,0 +1,56 @@ +package com.gg.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password}") + private String password; + + @Value("${spring.data.redis.database}") + private int database; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + if (password != null && !password.isBlank()) { + config.setPassword(password); + } + config.setDatabase(database); + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + return template; + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/RedisConnectionChecker.java b/gg_web/gg_backend/src/main/java/com/gg/config/RedisConnectionChecker.java new file mode 100644 index 0000000..fb3557b --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/RedisConnectionChecker.java @@ -0,0 +1,45 @@ +package com.gg.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Redis 연결 상태를 애플리케이션 시작 시 한 번만 확인합니다. + */ +@Component +public class RedisConnectionChecker implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(RedisConnectionChecker.class); + private static volatile boolean redisAvailable = false; + + private final StringRedisTemplate redisTemplate; + + public RedisConnectionChecker(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public void run(ApplicationArguments args) { + try { + redisTemplate.getConnectionFactory().getConnection().ping(); + redisAvailable = true; + log.info("✅ Redis 연결 확인 완료"); + } catch (Exception e) { + redisAvailable = false; + log.warn("⚠️ Redis 연결 확인 실패: {}. Redis 기능이 제한될 수 있습니다.", e.getMessage()); + } + } + + /** + * Redis 연결 상태를 확인합니다. + * @return Redis 연결 가능 여부 + */ + public static boolean isRedisAvailable() { + return redisAvailable; + } +} + diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/SwaggerConfig.java b/gg_web/gg_backend/src/main/java/com/gg/config/SwaggerConfig.java new file mode 100644 index 0000000..0e91183 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/SwaggerConfig.java @@ -0,0 +1,33 @@ +package com.gg.config; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.security.config.Elements.JWT; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class SwaggerConfig { + + @Bean + OpenAPI openAPI() { + SecurityScheme securityScheme = new SecurityScheme() + .name(JWT) + .type(SecurityScheme.Type.HTTP) + .in(SecurityScheme.In.HEADER) + .scheme("bearer") + .bearerFormat(JWT) + .name(AUTHORIZATION) + .description("인증 토큰을 입력해주세요 (Bearer 제외)"); + + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(JWT)) + .components(new Components().addSecuritySchemes(JWT, securityScheme)); + } + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/security/CustomAuthenticationFilter.java b/gg_web/gg_backend/src/main/java/com/gg/config/security/CustomAuthenticationFilter.java new file mode 100644 index 0000000..99024da --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/security/CustomAuthenticationFilter.java @@ -0,0 +1,53 @@ +package com.gg.config.security; + +import com.gg.common.jwt.JwtProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ") && + SecurityContextHolder.getContext().getAuthentication() == null) { + + String token = authHeader.substring("Bearer ".length()); + + if (jwtProvider.validateToken(token)) { + Integer userId = jwtProvider.getUserIdFromToken(token); + Long steamId = jwtProvider.getSteamIdFromToken(token); + String userName = jwtProvider.getUserNameFromToken(token); + + CustomUserDetails userDetails = CustomUserDetails.fromJwtClaims(userId, steamId, userName); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/security/CustomUserDetails.java b/gg_web/gg_backend/src/main/java/com/gg/config/security/CustomUserDetails.java new file mode 100644 index 0000000..cce0796 --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/security/CustomUserDetails.java @@ -0,0 +1,69 @@ +package com.gg.config.security; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import com.gg.application.auth.repository.AuthRepository.LoginProjection; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Integer userId; + private final Long steamId; + private final String userName; + + public CustomUserDetails(Integer userId, Long steamId, String userName) { + this.userId = userId; + this.steamId = steamId; + this.userName = userName; + } + + public static CustomUserDetails fromLoginProjection(LoginProjection p) { + return new CustomUserDetails(p.getUserId(), p.getSteamId(), p.getUserName()); + } + + public static CustomUserDetails fromJwtClaims(Integer userId, Long steamId, String userName) { + return new CustomUserDetails(userId, steamId, userName); + } + + @Override + public Collection getAuthorities() { + // ROLE 접두사 붙이기 (Spring Security 표준) + return Collections.singleton(() -> "ROLE_USER"); + } + + // 비밀번호 인증을 사용하지 않기 때문에 null 반환 + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/security/SecurityConfig.java b/gg_web/gg_backend/src/main/java/com/gg/config/security/SecurityConfig.java new file mode 100644 index 0000000..571904d --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/security/SecurityConfig.java @@ -0,0 +1,107 @@ +package com.gg.config.security; + +import com.gg.config.security.CustomAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.ForwardedHeaderFilter; + +import java.util.List; + +@Configuration +@EnableMethodSecurity(securedEnabled = true) +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomAuthenticationFilter customAuthenticationFilter; + + private static final String[] PUBLIC_ENDPOINTS = { + "/api/auth/**", "/api/games/**", "/api/library/**" + ,"/api/akinator/**", "/py/**", "/api/attribute/**", + "/actuator/**" + +// "/manage/health", "/manage/health/**", +// "/manage/info", "/manage/prometheus", + }; + + private static final String[] OPEN_API_ENDPOINT = { + "/swagger/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/swagger-resources/**", + "/webjars/**", + "/configuration/**" + }; + + private static final String[] NEED_AUTHENTICATION_ENDPOINT = { + "/api/review/**" + }; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .authorizeHttpRequests(auth -> { + auth + .requestMatchers(HttpMethod.GET, "/api/review/*/list").permitAll() + .requestMatchers(NEED_AUTHENTICATION_ENDPOINT).authenticated() + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(OPEN_API_ENDPOINT).permitAll() + .requestMatchers("/error").permitAll() + .anyRequest().authenticated(); // 모든 요청에 인증 요구 + }) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); + + http.addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:5173", "https://localhost:5173" + , "http://localhost:8080", "https://localhost:8080")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Authorization", "SET_COOKIE")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/gg_web/gg_backend/src/main/java/com/gg/config/security/SecurityHttpConfigurer.java b/gg_web/gg_backend/src/main/java/com/gg/config/security/SecurityHttpConfigurer.java new file mode 100644 index 0000000..cc2930a --- /dev/null +++ b/gg_web/gg_backend/src/main/java/com/gg/config/security/SecurityHttpConfigurer.java @@ -0,0 +1,20 @@ +package com.gg.config.security; + +import com.gg.config.security.CustomAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SecurityHttpConfigurer extends AbstractHttpConfigurer { + + private final CustomAuthenticationFilter customAuthenticationFilter; + + @Override + public void configure(HttpSecurity http){ + http.addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + } +} \ No newline at end of file diff --git a/gg_web/gg_backend/src/main/resources/application.properties b/gg_web/gg_backend/src/main/resources/application.properties new file mode 100644 index 0000000..6f8a219 --- /dev/null +++ b/gg_web/gg_backend/src/main/resources/application.properties @@ -0,0 +1,89 @@ +spring.application.name=GG + +spring.config.import=optional:file:.env[.properties] + +# Server Settings +build.date=@build.date@ +server.forward-headers-strategy=framework +server.port=${SERVER_PORT} +server.address=${SERVER_ADDRESS} +server.servlet.contextPath=/ +# Charset of HTTP requests and responses. Added to the "Content-Type" header if not set explicitly. +server.servlet.encoding.charset=UTF-8 +# Enable http encoding support. +server.servlet.encoding.enabled=true +# Force the encoding to the configured charset on HTTP requests and responses. +server.servlet.encoding.force=true +server.servlet.session.cookie.secure=true +server.tomcat.remoteip.remote-ip-header=x-forwarded-for +server.tomcat.remoteip.protocol-header=x-forwarded-proto + +#steam +steam.api.key=${STEAM_API_KEY} + +#login +steam.realm=${DOMAIN_URL} +steam.callback=${CALLBACK_URL} +steam.redirect=${REDIRECT_URL} + +# jwt +spring.jwt.secret=${JWT_SECRET} +spring.jwt.access-token-expiration-ms=${JWT_ACCESS_TOKEN_EXPIRATION_MS} +spring.jwt.refresh-token-expiration-ms=${JWT_REFRESH_TOKEN_EXPIRATION_MS} + +#database +spring.data.web.pageable.one-indexed-parameters=true +spring.datasource.url=${DATABASE_URL} +spring.datasource.username=${DATABASE_USERNAME} +spring.datasource.password=${DATABASE_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver + +#jpa +spring.jpa.hibernate.naming.implicit-strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.default_batch_fetch_size=1000 + +#logging +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.orm.jdbc.bind=trace +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE +logging.level.org.springframework.web=DEBUG +logging.level.com.cupid.qufit=DEBUG +logging.level.com.gg=DEBUG + +# ===== Actuator & Prometheus ===== +management.endpoints.web.exposure.include=prometheus,health,info +management.endpoints.web.access=read-write +management.metrics.tags.application=gg-backend + +# Timezone Setting +spring.jackson.time-zone=Asia/Seoul + +# Redis +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} +spring.data.redis.password=${REDIS_PASSWORD} +spring.data.redis.database=${REDIS_DB} +spring.data.redis.timeout=10000 + +# Cache (Caffeine + Redis 2단계 캐싱) +# 1차 캐시 (인메모리 Caffeine) 설정 +cache.caffeine.tag-filter.max-size=10000 +cache.caffeine.tag-filter.expire-after-write-minutes=30 +cache.caffeine.game-detail.max-size=1000 +cache.caffeine.game-detail.expire-after-write-hours=1 +cache.caffeine.game-media.max-size=1000 +cache.caffeine.game-media.expire-after-write-hours=2 +cache.caffeine.attributes.max-size=10 +cache.caffeine.attributes.expire-after-write-hours=12 +cache.caffeine.game-count.max-size=1000 +cache.caffeine.game-count.expire-after-write-minutes=30 + +# 2차 캐시 (Redis) TTL 설정 (시간 단위) +cache.redis.tag-filter.ttl-hours=1 +cache.redis.game-detail.ttl-hours=2 +cache.redis.game-media.ttl-hours=2 +cache.redis.attributes.ttl-hours=24 +cache.redis.game-count.ttl-hours=1 \ No newline at end of file diff --git a/gg_web/gg_backend/src/test/java/com/gg/GgApplicationTests.java b/gg_web/gg_backend/src/test/java/com/gg/GgApplicationTests.java new file mode 100644 index 0000000..98a5074 --- /dev/null +++ b/gg_web/gg_backend/src/test/java/com/gg/GgApplicationTests.java @@ -0,0 +1,13 @@ +package com.gg; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class GgApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/gg_web/gg_frontend/.dockerignore b/gg_web/gg_frontend/.dockerignore new file mode 100644 index 0000000..f514388 --- /dev/null +++ b/gg_web/gg_frontend/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +.gradle +build +**/.DS_Store +**/node_modules \ No newline at end of file diff --git a/gg_web/gg_frontend/.gitignore b/gg_web/gg_frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/gg_web/gg_frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/gg_web/gg_frontend/Dockerfile b/gg_web/gg_frontend/Dockerfile new file mode 100644 index 0000000..fe10bef --- /dev/null +++ b/gg_web/gg_frontend/Dockerfile @@ -0,0 +1,18 @@ +# gg_frontend/Dockerfile +FROM node:20-alpine + +WORKDIR /app + +# 빌드 타임 환경변수(원하면 compose에서 넘기면 됨) +ARG VITE_API_BASE_URL +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +# 패키지 매니저/lock 파일 자동 감지 후 설치 → 빌드 +# (호스트의 ./gg_frontend 가 /app 으로 마운트되므로 복사 불필요) +CMD [ "sh", "-lc", "\ + if [ -f pnpm-lock.yaml ]; then npm -g i pnpm && pnpm i --frozen-lockfile; \ + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ + else npm i --no-audit --no-fund; fi && \ + npm run build \ +" ] diff --git a/gg_web/gg_frontend/README.md b/gg_web/gg_frontend/README.md new file mode 100644 index 0000000..eb5ef22 --- /dev/null +++ b/gg_web/gg_frontend/README.md @@ -0,0 +1,106 @@ +# 게임 추천 플랫폼 + +비디오 게임을 발견하고, 추적하며, 추천받을 수 있는 현대적이고 반응형인 웹 애플리케이션입니다. React, Vite, TypeScript, Tailwind CSS를 사용한 견고한 프론트엔드 아키텍처로 구축되었습니다. + +![게임 추천 플랫폼 스크린샷](https://i.imgur.com/example.png) + +## ✨ 주요 기능 + +- **동적 게임 탐색**: 인기 게임, 신규 게임, 추천 게임을 둘러볼 수 있습니다. +- **고급 검색 및 필터링**: 강력한 정렬 및 필터링 기능을 갖춘 전체 텍스트 검색을 지원합니다. +- **URL 기반 상태 관리**: 필터, 정렬, 페이지네이션 상태가 URL과 동기화되어, 페이지를 공유하거나 딥링크로 직접 접근할 수 있습니다. +- **상세 게임 페이지**: 게임 설명, 스크린샷, 평점 및 유사한 게임 정보를 볼 수 있습니다. +- **사용자 프로필**: '좋아요'한 게임을 추적하고 개인화된 추천을 확인할 수 있습니다. +- **라이트 & 다크 모드**: 로컬 스토리지를 통해 테마 상태를 유지하며, 자연스러운 테마 전환을 제공합니다. +- **완벽한 반응형 디자인**: 모바일 우선 디자인으로 모든 화면 크기에서 멋지게 보입니다. +- **고성능**: `React.lazy`와 `Suspense`를 이용한 라우트 레벨 코드 스플리팅으로 초기 로딩 속도를 최적화했습니다. +- **웹 접근성**: 키보드 네비게이션, ARIA 속성 등 웹 접근성을 고려하여 제작되었습니다. + +## 🚀 기술 스택 + +- **프레임워크**: [React](https://reactjs.org/) +- **빌드 도구**: [Vite](https://vitejs.dev/) +- **언어**: [TypeScript](https://www.typescriptlang.org/) +- **라우팅**: [React Router v6](https://reactrouter.com/) +- **스타일링**: [Tailwind CSS](https://tailwindcss.com/) +- **아이콘**: [Lucide React](https://lucide.dev/) +- **린팅/포맷팅**: (ESLint/Prettier 추가 가능) + +## 📂 프로젝트 구조 + +이 프로젝트는 코드베이스를 체계적이고 확장 가능하게 유지하기 위해 기능 중심의 구조를 따릅니다. + +``` +/src +├── App.tsx # 라우팅 설정이 포함된 메인 애플리케이션 컴포넌트 +├── components/ # 재사용 가능한 공용 UI 컴포넌트 (예: GameCard, Header) +├── contexts/ # React Context 프로바이더 (예: ThemeProvider) +├── lib/ # 유틸리티 함수 및 커스텀 훅 (예: useQueryParams) +├── mocks/ # 개발을 위한 Mock API 및 데이터 +├── routes/ # 페이지 레벨 컴포넌트 (예: Home, Profile, GameDetail) +├── types.ts # 전역 TypeScript 타입 정의 +└── index.css # 전역 스타일 및 Tailwind CSS 설정 +``` + +## ⚙️ 시작하기 + +로컬 컴퓨터에서 프로젝트를 설정하고 실행하려면 다음 지침을 따르세요. + +### 사전 준비 + +- [Node.js](https://nodejs.org/) (v18 이상 권장) +- [npm](https://www.npmjs.com/) 또는 [yarn](https://yarnpkg.com/) + +### 설치 및 설정 + +1. **저장소 클론:** + ```bash + git clone <저장소-URL> + cd <저장소-디렉토리> + ``` + +2. **의존성 설치:** + ```bash + npm install + ``` + +3. **개발 서버 실행:** + ```bash + npm run dev + ``` + 애플리케이션은 `http://localhost:` 에서 실행됩니다 (만약 5173 포트가 사용 중이면 다른 포트에서 실행됩니다). + +### 사용 가능한 스크립트 + +- `npm run dev`: HMR(Hot Module Replacement)이 적용된 Vite 개발 서버를 시작합니다. +- `npm run build`: 프로덕션용으로 애플리케이션을 컴파일하고 번들링합니다. +- `npm run preview`: 프로덕션 빌드를 로컬에서 실행하여 배포 전에 미리 확인합니다. + +## 🧠 아키텍처 결정 사항 + +### 라우팅 및 코드 스플리팅 + +- **React Router**: 모든 클라이언트 사이드 라우팅을 관리합니다. +- **지연 로딩(Lazy Loading)**: `src/routes/` 아래의 모든 페이지 레벨 컴포넌트는 `React.lazy()`를 사용하여 비동기적으로 로드되며, `App.tsx`에서 ``로 감싸집니다. 이를 통해 초기 페이지 로딩 시간을 크게 개선했습니다. +- **레이아웃 라우트**: `Layout.tsx` 컴포넌트가 메인 페이지들을 감싸며 일관된 `Header`를 제공합니다. 또한 홈페이지(`/`)에서만 `Sidebar`를 조건부로 렌더링합니다. + +### 상태 관리 + +- **URL을 상태로 사용**: 필터나 정렬과 같은 전역 상태는 URL 쿼리 파라미터를 단일 정보 소스(Single Source of Truth)로 사용합니다. 커스텀 훅인 `useQueryParams`는 URL을 읽고 쓰는 로직을 추상화하여, 컴포넌트를 선언적으로 만들고 상태를 쉽게 공유할 수 있도록 합니다. +- **React Context**: 현재 테마처럼 URL에 저장할 필요가 없는 UI 상태는 React Context를 사용합니다. `ThemeProvider`는 모든 컴포넌트에 테마 상태와 토글 함수를 제공합니다. + +### 스타일링 + +- **Tailwind CSS**: 모든 스타일링에 유틸리티 우선 CSS 프레임워크인 Tailwind CSS를 사용합니다. +- **CSS 변수**: `index.css`에 라이트/다크 모드에 대한 커스텀 테마(색상, 간격, 반지름 등)를 CSS 변수를 사용하여 정의했습니다. 이를 통해 일관성 있고 관리하기 쉬운 테마를 구현했습니다. +- **다크 모드**: `ThemeProvider`를 통해 `` 요소에 `.dark` 클래스를 추가하거나 제거하는 방식으로 테마를 제어합니다. + +### 데이터 페칭 + +- **Mock API**: 현재 이 애플리케이션은 `src/mocks/api.ts`에 위치한 Mock API를 사용합니다. 이는 실제와 유사한 딜레이를 가진 비동기 데이터 페칭을 시뮬레이션하며, 필터링, 페이지네이션, 정렬을 지원합니다. 실제 백엔드 API로 쉽게 교체할 수 있도록 설계되었습니다. + +### 개발자 경험 + +- **절대 경로 임포트**: Vite와 TypeScript에 경로 별칭(예: `@/components/*`)을 설정하여 더 깔끔하고 유지보수하기 쉬운 임포트 구문을 사용합니다. +- **TypeScript**: 전체 코드베이스는 TypeScript로 작성되어, 정적 타이핑을 통해 오류를 조기에 발견하고 코드 품질을 향상시킵니다. + diff --git a/gg_web/gg_frontend/components.json b/gg_web/gg_frontend/components.json new file mode 100644 index 0000000..1e16ad4 --- /dev/null +++ b/gg_web/gg_frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/shared/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/gg_web/gg_frontend/eslint.config.ts b/gg_web/gg_frontend/eslint.config.ts new file mode 100644 index 0000000..3a646cb --- /dev/null +++ b/gg_web/gg_frontend/eslint.config.ts @@ -0,0 +1,19 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import pluginReact from "eslint-plugin-react"; +import { defineConfig } from "eslint/config"; + +const ignores = ["dist/**", "node_modules/**", "coverage/**"]; + +export default defineConfig([ + { + files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + plugins: { js }, + extends: ["js/recommended"], + languageOptions: { globals: globals.browser }, + ignores, + }, + tseslint.configs.recommended, + pluginReact.configs.flat.recommended, +]); diff --git a/gg_web/gg_frontend/index.css b/gg_web/gg_frontend/index.css new file mode 100644 index 0000000..66186f2 --- /dev/null +++ b/gg_web/gg_frontend/index.css @@ -0,0 +1,144 @@ +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css'); +@import './src/style/glass.css'; + + +@tailwind base; +@tailwind components; +@tailwind utilities; + + +:root { + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif; + + /* Light Theme */ + --background: #F8F8FA; + --surface: #FFFFFF; + --card: #FFFFFF; + --text-primary: #1E1E1E; + --text-muted: #6B7280; + --lines: #E5E7EB; + --accent: #6C63FF; + --accent-hover: #7B75FF; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-2xl: 24px; +} + + html.dark { + --background: hsl(0 0% 3.9%); + --surface: hsl(0 0% 10%); + --card: hsl(0 0% 10%); + --text-primary: hsl(0 0% 98%); + --text-muted: hsl(0 0% 63.9%); + --lines: hsl(0 0% 20%); + --accent: hsl(0 0% 14.9%); + --accent-hover: hsl(0 0% 9%); + } + +body { + background-color: var(--background); + color: var(--text-primary); + transition: background-color 0.3s ease, color 0.3s ease; + margin: 0; + padding: 0; +} + +html { + /* Reserve scrollbar space so the layout/header doesn't shift on tall pages */ + scrollbar-gutter: stable both-edges; +} + +/* Custom shadows */ +.shadow-soft { + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); +} +.hover\:shadow-strong:hover { + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); +} +html.dark .shadow-soft { + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.15), 0 2px 4px -2px rgb(0 0 0 / 0.15); +} +html.dark .hover\:shadow-strong:hover { + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.25), 0 4px 6px -4px rgb(0 0 0 / 0.25); +} + +/* Accessibility focus ring */ +.focus-ring { + @apply focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-accent focus-visible:ring-offset-background; +} + +@layer base { + :root { + --background: hsl(0 0% 100%); + --foreground: hsl(0 0% 3.9%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(0 0% 3.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(0 0% 3.9%); + --primary: hsl(0 0% 9%); + --primary-foreground: hsl(0 0% 98%); + --secondary: hsl(0 0% 96.1%); + --secondary-foreground: hsl(0 0% 9%); + --muted: hsl(0 0% 96.1%); + --muted-foreground: hsl(0 0% 45.1%); + --accent: hsl(243 100% 69%); + --accent-foreground: hsl(0 0% 9%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(0 0% 89.8%); + --input: hsl(0 0% 89.8%); + --ring: hsl(0 0% 3.9%); + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + --radius: 0.5rem; + } + .dark { + --background: hsl(0 0% 3.9%); + --foreground: hsl(0 0% 98%); + --card: hsl(0 0% 3.9%); + --card-foreground: hsl(0 0% 98%); + --popover: hsl(0 0% 3.9%); + --popover-foreground: hsl(0 0% 98%); + --primary: hsl(0 0% 98%); + --primary-foreground: hsl(0 0% 9%); + --secondary: hsl(0 0% 14.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(0 0% 14.9%); + --muted-foreground: hsl(0 0% 63.9%); + --accent: hsl(0 0% 14.9%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(0 0% 14.9%); + --input: hsl(0 0% 14.9%); + --ring: hsl(0 0% 83.1%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + + +@layer utilities { + /* 전체 페이지 스크롤바 스타일 */ + html { + @apply scrollbar-thin scrollbar-thumb-zinc-600 scrollbar-track-transparent hover:scrollbar-thumb-zinc-500; + } +} diff --git a/gg_web/gg_frontend/index.html b/gg_web/gg_frontend/index.html new file mode 100644 index 0000000..b8e1c6c --- /dev/null +++ b/gg_web/gg_frontend/index.html @@ -0,0 +1,32 @@ + + + + + + + + Good Game finder + + + + + +
+ + + + \ No newline at end of file diff --git a/gg_web/gg_frontend/index.tsx b/gg_web/gg_frontend/index.tsx new file mode 100644 index 0000000..cbf779d --- /dev/null +++ b/gg_web/gg_frontend/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import App from '@/App'; +import './index.css'; +import { queryClient, queryClientReady } from '@/shared/lib/reactQueryClient'; + +const rootElement = document.getElementById('root') as HTMLElement; +const root = ReactDOM.createRoot(rootElement); + +const render = () => { + root.render( + + + + + , + ); +}; + +queryClientReady.then(render); diff --git a/gg_web/gg_frontend/metadata.json b/gg_web/gg_frontend/metadata.json new file mode 100644 index 0000000..99dac5c --- /dev/null +++ b/gg_web/gg_frontend/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "게임 페이지 1차", + "description": "An empty app", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/gg_web/gg_frontend/package-lock.json b/gg_web/gg_frontend/package-lock.json new file mode 100644 index 0000000..5bb1997 --- /dev/null +++ b/gg_web/gg_frontend/package-lock.json @@ -0,0 +1,10273 @@ +{ + "name": "game-recommendation-platform", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "game-recommendation-platform", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@tanstack/query-persist-client-core": "^5.91.4", + "@tanstack/react-query": "^5.90.5", + "add": "^2.0.6", + "axios": "^1.13.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", + "lucide-react": "^0.363.0", + "pagination": "^0.4.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-router-dom": "^6.22.3", + "recharts": "^2.10.3", + "shadcn": "^3.5.0", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^20.11.30", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "globals": "^16.5.0", + "jiti": "^2.6.1", + "postcss": "^8.4.38", + "tailwind-scrollbar": "^3.1.0", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "typescript-eslint": "^8.46.4", + "vite": "^7.1.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@antfu/ni": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz", + "integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==", + "license": "MIT", + "dependencies": { + "ansis": "^4.0.0", + "fzf": "^0.5.2", + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "na": "bin/na.mjs", + "nci": "bin/nci.mjs", + "ni": "bin/ni.mjs", + "nlx": "bin/nlx.mjs", + "nr": "bin/nr.mjs", + "nun": "bin/nun.mjs", + "nup": "bin/nup.mjs" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", + "integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.1.tgz", + "integrity": "sha512-fqcQxcxC4LOaUlW8IkyWw8x0yirlLUkbxohz9OnWvVWjf73J5yyw7jxWnkOJaUKXZotcGEScDox9MU6rSkcDgg==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", + "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz", + "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.8.tgz", + "integrity": "sha512-4E0RP/0GJCxSNiRF2kAqE/LQkTJVlL/QNU7gIJSptaseV9HP6kOuA+N11y4bZKZxa3QopK3ZuewwutHx6DqDXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.91.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.91.7.tgz", + "integrity": "sha512-MKJmuHl4LIl3Zs91fL9CQx1XJ8//1bnq8keY5V+cv3SLkPM6Wqx/HYFBcevfFFrzo9TDHob9ivuM4UOGTyQ7Mg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.8.tgz", + "integrity": "sha512-/3b9QGzkf4rE5/miL6tyhldQRlLXzMHcySOm/2Tm2OLEFE9P1ImkH0+OviDBSvyAvtAOJocar5xhd7vxdLi3aQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", + "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/type-utils": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.4", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", + "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", + "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.4", + "@typescript-eslint/types": "^8.46.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", + "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", + "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", + "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", + "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", + "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.4", + "@typescript-eslint/tsconfig-utils": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", + "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", + "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/add": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/add/-/add-2.0.6.tgz", + "integrity": "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", + "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.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", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "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", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/eciesjs": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz", + "integrity": "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.4", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.251", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.251.tgz", + "integrity": "sha512-lmyEOp4G0XT3qrYswNB4np1kx90k6QCXpnSHYv2xEsUuEu8JCobpDVYO6vMseirQyyCC6GCIGGxd5szMBa0tRA==", + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT", + "peer": true + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/fzf": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", + "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", + "license": "BSD-3-Clause" + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.363.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.363.0.tgz", + "integrity": "sha512-AlsfPCsXQyQx7wwsIgzcKOL9LwC498LIMAo+c0Es5PkHJa33xwmYAkkSoKoJWWWSYQEStqu58/jT4tL2gi32uQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.1.tgz", + "integrity": "sha512-arzsi9IZjjByiEw21gSUP82qHM8zkV69nNpWV6W4z72KiLvsDWoOp678ORV6cNfU/JGhlX0SsnD4oXo9gI6I2A==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.40.0", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^4.26.1", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "license": "MIT" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.5.0.tgz", + "integrity": "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==", + "license": "MIT" + }, + "node_modules/pagination": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/pagination/-/pagination-0.4.6.tgz", + "integrity": "sha512-TnZ3l17uuC5H9Wlc9FMYuEi65JlUCSn1ZAbnLnvUGkR7RgaFknUNGQTEumhjq1Jy1ePkXSiYUFFbHH70LGb9dA==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "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", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.5.0.tgz", + "integrity": "sha512-5f9tn7gHOiI8CqJ8LGrUEmX6dOivGgSbHkMCeOMCzxHZy9cPwyuRXt7ZyjwukO8QH9kGqi6oiKOpfvTXNGBP4g==", + "license": "MIT", + "dependencies": { + "@antfu/ni": "^25.0.0", + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.17.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "https-proxy-agent": "^7.0.6", + "kleur": "^4.1.5", + "msw": "^2.10.4", + "node-fetch": "^3.3.2", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-scrollbar": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-3.1.0.tgz", + "integrity": "sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13.0" + }, + "peerDependencies": { + "tailwindcss": "3.x" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.17" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", + "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.46.4", + "@typescript-eslint/parser": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/gg_web/gg_frontend/package.json b/gg_web/gg_frontend/package.json new file mode 100644 index 0000000..6085d1d --- /dev/null +++ b/gg_web/gg_frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "game-recommendation-platform", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@tanstack/query-persist-client-core": "^5.91.4", + "@tanstack/react-query": "^5.90.5", + "add": "^2.0.6", + "axios": "^1.13.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", + "lucide-react": "^0.363.0", + "pagination": "^0.4.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-router-dom": "^6.22.3", + "recharts": "^2.10.3", + "shadcn": "^3.5.0", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^20.11.30", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "globals": "^16.5.0", + "jiti": "^2.6.1", + "postcss": "^8.4.38", + "tailwind-scrollbar": "^3.1.0", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "typescript-eslint": "^8.46.4", + "vite": "^7.1.10" + } +} diff --git a/gg_web/gg_frontend/postcss.config.js b/gg_web/gg_frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/gg_web/gg_frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/gg_web/gg_frontend/public/akinator/aki-bg.png b/gg_web/gg_frontend/public/akinator/aki-bg.png new file mode 100644 index 0000000..6b9224b --- /dev/null +++ b/gg_web/gg_frontend/public/akinator/aki-bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa1e7c1bffbdc275d103c89539a07e0030f233bda3d4f140f36c89f907e4fa9d +size 2174553 diff --git a/gg_web/gg_frontend/public/akinator/squirrel_back.png b/gg_web/gg_frontend/public/akinator/squirrel_back.png new file mode 100644 index 0000000..97f8f8e --- /dev/null +++ b/gg_web/gg_frontend/public/akinator/squirrel_back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:801bf8279fe86d90c34003f6d5e60c92a6e2fbc9779db4031991f228a9a629c7 +size 822896 diff --git a/gg_web/gg_frontend/public/akinator/squirrel_idle.png b/gg_web/gg_frontend/public/akinator/squirrel_idle.png new file mode 100644 index 0000000..f6ae0ce --- /dev/null +++ b/gg_web/gg_frontend/public/akinator/squirrel_idle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:796855e3f0a09212989b7eac82891f7319ff87fa66b601af2a0280a554f66367 +size 923212 diff --git a/gg_web/gg_frontend/public/akinator/squirrel_search.png b/gg_web/gg_frontend/public/akinator/squirrel_search.png new file mode 100644 index 0000000..4ba9fea --- /dev/null +++ b/gg_web/gg_frontend/public/akinator/squirrel_search.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0795e6f567634fbe9e6998593958fcd65d2ea9ac040b55990540957f66f2332 +size 773768 diff --git a/gg_web/gg_frontend/public/akinator/squirrel_think.png b/gg_web/gg_frontend/public/akinator/squirrel_think.png new file mode 100644 index 0000000..6558cac --- /dev/null +++ b/gg_web/gg_frontend/public/akinator/squirrel_think.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ceec122703654ebd92fafc01d9e63acb0e1a9cdf0d708a6017b628191a88b49d +size 847495 diff --git a/gg_web/gg_frontend/public/akinator/squirrel_think2.png b/gg_web/gg_frontend/public/akinator/squirrel_think2.png new file mode 100644 index 0000000..063f0d8 --- /dev/null +++ b/gg_web/gg_frontend/public/akinator/squirrel_think2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0452e3c7e5d5a03cae60c47141f39f5877fdd70d85db107096e929d7d7ad984e +size 859178 diff --git a/gg_web/gg_frontend/public/cat_move.mp4 b/gg_web/gg_frontend/public/cat_move.mp4 new file mode 100644 index 0000000..70c211c --- /dev/null +++ b/gg_web/gg_frontend/public/cat_move.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5399779cf6129b86ae1bd3008f1ac4e362b119272822855dc0df3b4aff47d11 +size 1257901 diff --git a/gg_web/gg_frontend/public/chatbot.png b/gg_web/gg_frontend/public/chatbot.png new file mode 100644 index 0000000..cb0f03c --- /dev/null +++ b/gg_web/gg_frontend/public/chatbot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df0f68a91cf419ed65505c835db8a0d4fd6492de221a4f847bf9f24952884fb2 +size 1469438 diff --git a/gg_web/gg_frontend/public/gg-icon.png b/gg_web/gg_frontend/public/gg-icon.png new file mode 100644 index 0000000..c5bbb76 --- /dev/null +++ b/gg_web/gg_frontend/public/gg-icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c790b093f9eea6413abe10b53ce6bfdf3327df5db546691daee6d0966cc88f50 +size 73861 diff --git a/gg_web/gg_frontend/public/header-icon.png b/gg_web/gg_frontend/public/header-icon.png new file mode 100644 index 0000000..32892aa --- /dev/null +++ b/gg_web/gg_frontend/public/header-icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:805eab27bef509914f3d6f70e661494b824b86bc8e4de4f3b3f061294272783c +size 117117 diff --git a/gg_web/gg_frontend/public/login-prompt.jpeg b/gg_web/gg_frontend/public/login-prompt.jpeg new file mode 100644 index 0000000..2b874e4 Binary files /dev/null and b/gg_web/gg_frontend/public/login-prompt.jpeg differ diff --git a/gg_web/gg_frontend/public/videos/background.mp4 b/gg_web/gg_frontend/public/videos/background.mp4 new file mode 100644 index 0000000..5dee843 --- /dev/null +++ b/gg_web/gg_frontend/public/videos/background.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e60b0fd6e0c45075adbe63fe898aa0b350b5bc52b6c4b77bd752432d00d7a64 +size 30500009 diff --git a/gg_web/gg_frontend/src/App.tsx b/gg_web/gg_frontend/src/App.tsx new file mode 100644 index 0000000..96fad90 --- /dev/null +++ b/gg_web/gg_frontend/src/App.tsx @@ -0,0 +1,21 @@ +import { BrowserRouter } from 'react-router-dom'; +import { ThemeProvider } from '@/shared/contexts/ThemeProvider'; +import { AuthProvider } from '@/shared/contexts/AuthContext'; +import { LibraryProvider } from '@/shared/contexts/LibraryContext'; +import { AppRoutes } from '@/shared/routes/AppRoutes'; + +function App() { + return ( + + + + + + + + + + ); +} + +export default App; diff --git a/gg_web/gg_frontend/src/components/ui/button.tsx b/gg_web/gg_frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..4d3a362 --- /dev/null +++ b/gg_web/gg_frontend/src/components/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/gg_web/gg_frontend/src/components/ui/pagination.tsx b/gg_web/gg_frontend/src/components/ui/pagination.tsx new file mode 100644 index 0000000..a63a145 --- /dev/null +++ b/gg_web/gg_frontend/src/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/shared/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +