From 074b5133bfce033e80a194642c3a3fd522f3aa8c Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Thu, 12 Feb 2026 15:45:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=80=EC=8A=A4=ED=83=9D=20=ED=95=A0?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=EB=A6=AC=20=EC=95=B1=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(=ED=86=B5=ED=95=A9=20=EB=AA=A8=EB=8B=AC=20+=20=EA=B0=84?= =?UTF-8?q?=ED=8A=B8=EC=B0=A8=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드) - Frontend: Next.js 15 + Tailwind + React Query + Zustand - 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부) - 간트차트: 카테고리별 할일 타임라인 시각화 - TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시 - Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391) Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 1 + backend/app/config.py | 44 + backend/app/database.py | 98 + backend/app/main.py | 64 +- backend/app/models/__init__.py | 47 + backend/app/models/category.py | 49 + backend/app/models/common.py | 29 + backend/app/models/todo.py | 221 + backend/app/routers/__init__.py | 9 + backend/app/routers/categories.py | 55 + backend/app/routers/dashboard.py | 17 + backend/app/routers/search.py | 19 + backend/app/routers/tags.py | 16 + backend/app/routers/todos.py | 106 + backend/app/routers/uploads.py | 56 + backend/app/services/__init__.py | 11 + backend/app/services/category_service.py | 161 + backend/app/services/dashboard_service.py | 182 + backend/app/services/file_service.py | 154 + backend/app/services/search_service.py | 92 + backend/app/services/todo_service.py | 396 + backend/requirements.txt | 15 +- docker-compose.yml | 3 + docs/ARCHITECTURE.md | 2648 +++++++ docs/FEATURE_SPEC.md | 789 ++ docs/PLAN.md | 259 + docs/SCREEN_DESIGN.md | 312 + docs/SCREEN_DESIGN.pptx | Bin 0 -> 56363 bytes docs/TEST_REPORT.md | 320 + frontend/.gitignore | 39 + frontend/Dockerfile | 19 + frontend/eslint.config.mjs | 16 + frontend/next.config.ts | 7 + frontend/package-lock.json | 6439 +++++++++++++++++ frontend/package.json | 35 + frontend/postcss.config.mjs | 8 + frontend/src/app/categories/page.tsx | 122 + frontend/src/app/globals.css | 1 + frontend/src/app/layout.tsx | 28 + frontend/src/app/page.tsx | 48 + frontend/src/app/search/page.tsx | 60 + frontend/src/app/todos/[id]/page.tsx | 22 + frontend/src/app/todos/page.tsx | 239 + .../components/categories/CategoryForm.tsx | 106 + .../components/categories/CategoryItem.tsx | 63 + .../components/categories/CategoryList.tsx | 51 + .../src/components/categories/ColorPicker.tsx | 65 + .../src/components/common/AttachmentList.tsx | 78 + frontend/src/components/common/DatePicker.tsx | 53 + frontend/src/components/common/FileUpload.tsx | 73 + frontend/src/components/common/Pagination.tsx | 99 + .../src/components/common/PriorityBadge.tsx | 28 + frontend/src/components/common/TagBadge.tsx | 46 + frontend/src/components/common/TagInput.tsx | 99 + .../components/dashboard/CategoryChart.tsx | 95 + .../components/dashboard/PriorityChart.tsx | 106 + .../src/components/dashboard/StatsCards.tsx | 107 + .../dashboard/UpcomingDeadlines.tsx | 100 + frontend/src/components/layout/Header.tsx | 51 + frontend/src/components/layout/MainLayout.tsx | 22 + frontend/src/components/layout/Sidebar.tsx | 157 + .../components/providers/QueryProvider.tsx | 28 + frontend/src/components/search/SearchBar.tsx | 53 + .../src/components/search/SearchResults.tsx | 137 + .../src/components/todos/BatchActions.tsx | 109 + frontend/src/components/todos/GanttChart.tsx | 318 + frontend/src/components/todos/TodoCard.tsx | 160 + frontend/src/components/todos/TodoFilter.tsx | 111 + frontend/src/components/todos/TodoList.tsx | 78 + frontend/src/components/todos/TodoModal.tsx | 420 ++ frontend/src/hooks/useCategories.ts | 63 + frontend/src/hooks/useDashboard.ts | 15 + frontend/src/hooks/useGantt.ts | 128 + frontend/src/hooks/useSearch.ts | 24 + frontend/src/hooks/useTags.ts | 15 + frontend/src/hooks/useTodos.ts | 190 + frontend/src/lib/api.ts | 121 + frontend/src/lib/utils.ts | 135 + frontend/src/store/uiStore.ts | 90 + frontend/src/types/index.ts | 169 + frontend/tsconfig.json | 27 + 81 files changed, 17027 insertions(+), 19 deletions(-) create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/category.py create mode 100644 backend/app/models/common.py create mode 100644 backend/app/models/todo.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/categories.py create mode 100644 backend/app/routers/dashboard.py create mode 100644 backend/app/routers/search.py create mode 100644 backend/app/routers/tags.py create mode 100644 backend/app/routers/todos.py create mode 100644 backend/app/routers/uploads.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/category_service.py create mode 100644 backend/app/services/dashboard_service.py create mode 100644 backend/app/services/file_service.py create mode 100644 backend/app/services/search_service.py create mode 100644 backend/app/services/todo_service.py create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/FEATURE_SPEC.md create mode 100644 docs/PLAN.md create mode 100644 docs/SCREEN_DESIGN.md create mode 100644 docs/SCREEN_DESIGN.pptx create mode 100644 docs/TEST_REPORT.md create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/next.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/src/app/categories/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/search/page.tsx create mode 100644 frontend/src/app/todos/[id]/page.tsx create mode 100644 frontend/src/app/todos/page.tsx create mode 100644 frontend/src/components/categories/CategoryForm.tsx create mode 100644 frontend/src/components/categories/CategoryItem.tsx create mode 100644 frontend/src/components/categories/CategoryList.tsx create mode 100644 frontend/src/components/categories/ColorPicker.tsx create mode 100644 frontend/src/components/common/AttachmentList.tsx create mode 100644 frontend/src/components/common/DatePicker.tsx create mode 100644 frontend/src/components/common/FileUpload.tsx create mode 100644 frontend/src/components/common/Pagination.tsx create mode 100644 frontend/src/components/common/PriorityBadge.tsx create mode 100644 frontend/src/components/common/TagBadge.tsx create mode 100644 frontend/src/components/common/TagInput.tsx create mode 100644 frontend/src/components/dashboard/CategoryChart.tsx create mode 100644 frontend/src/components/dashboard/PriorityChart.tsx create mode 100644 frontend/src/components/dashboard/StatsCards.tsx create mode 100644 frontend/src/components/dashboard/UpcomingDeadlines.tsx create mode 100644 frontend/src/components/layout/Header.tsx create mode 100644 frontend/src/components/layout/MainLayout.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/providers/QueryProvider.tsx create mode 100644 frontend/src/components/search/SearchBar.tsx create mode 100644 frontend/src/components/search/SearchResults.tsx create mode 100644 frontend/src/components/todos/BatchActions.tsx create mode 100644 frontend/src/components/todos/GanttChart.tsx create mode 100644 frontend/src/components/todos/TodoCard.tsx create mode 100644 frontend/src/components/todos/TodoFilter.tsx create mode 100644 frontend/src/components/todos/TodoList.tsx create mode 100644 frontend/src/components/todos/TodoModal.tsx create mode 100644 frontend/src/hooks/useCategories.ts create mode 100644 frontend/src/hooks/useDashboard.ts create mode 100644 frontend/src/hooks/useGantt.ts create mode 100644 frontend/src/hooks/useSearch.ts create mode 100644 frontend/src/hooks/useTags.ts create mode 100644 frontend/src/hooks/useTodos.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/store/uiStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.json diff --git a/backend/Dockerfile b/backend/Dockerfile index 35f21ba..0a27a22 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,5 +4,6 @@ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app/ +RUN mkdir -p /app/uploads EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..84605ce --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,44 @@ +from pydantic_settings import BaseSettings +from pydantic import Field +from functools import lru_cache + + +class Settings(BaseSettings): + mongodb_url: str = Field( + default="mongodb://mongodb:27017", + description="MongoDB 연결 URL", + ) + mongodb_database: str = Field( + default="todos2", + alias="DB_NAME", + description="MongoDB 데이터베이스 이름", + ) + redis_url: str = Field( + default="redis://redis:6379", + description="Redis 연결 URL", + ) + frontend_url: str = Field( + default="http://localhost:3000", + description="프론트엔드 URL (CORS 허용)", + ) + upload_dir: str = Field( + default="/app/uploads", + description="파일 업로드 디렉토리", + ) + max_file_size: int = Field( + default=10 * 1024 * 1024, + description="최대 파일 크기 (bytes, 기본 10MB)", + ) + max_files_per_todo: int = Field( + default=5, + description="Todo당 최대 첨부 파일 수", + ) + + class Config: + env_file = ".env" + populate_by_name = True # alias와 필드명 모두 허용 + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..015f1aa --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,98 @@ +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +import redis.asyncio as aioredis +from app.config import get_settings + + +class Database: + client: AsyncIOMotorClient | None = None + db: AsyncIOMotorDatabase | None = None + redis: aioredis.Redis | None = None + + +db = Database() + + +def get_database() -> AsyncIOMotorDatabase: + """MongoDB 데이터베이스 인스턴스를 반환한다. + 주의: `if not db.db:` 사용 금지 (pymongo 4.9.x NotImplementedError) + """ + if db.db is None: + raise RuntimeError("Database not initialized") + return db.db + + +def get_redis() -> aioredis.Redis: + """Redis 클라이언트 인스턴스를 반환한다.""" + if db.redis is None: + raise RuntimeError("Redis not initialized") + return db.redis + + +async def connect_db(): + """MongoDB + Redis 연결 초기화""" + settings = get_settings() + + # MongoDB + db.client = AsyncIOMotorClient(settings.mongodb_url) + db.db = db.client[settings.mongodb_database] + + # 인덱스 생성 + await create_indexes(db.db) + + # Redis + db.redis = aioredis.from_url( + settings.redis_url, + encoding="utf-8", + decode_responses=True, + ) + + +async def disconnect_db(): + """연결 종료""" + if db.client is not None: + db.client.close() + if db.redis is not None: + await db.redis.close() + + +async def create_indexes(database: AsyncIOMotorDatabase): + """컬렉션 인덱스 생성""" + todos = database["todos"] + categories = database["categories"] + + # todos 텍스트 검색 인덱스 + await todos.create_index( + [("title", "text"), ("content", "text"), ("tags", "text")], + name="text_search_index", + weights={"title": 10, "content": 5, "tags": 3}, + ) + + # todos 복합 인덱스 + await todos.create_index( + [("category_id", 1), ("created_at", -1)], + name="category_created", + ) + await todos.create_index( + [("completed", 1), ("created_at", -1)], + name="completed_created", + ) + await todos.create_index( + [("priority", 1), ("created_at", -1)], + name="priority_created", + ) + await todos.create_index( + [("tags", 1)], + name="tags", + ) + await todos.create_index( + [("due_date", 1)], + name="due_date", + ) + await todos.create_index( + [("completed", 1), ("due_date", 1)], + name="completed_due_date", + ) + + # categories 인덱스 + await categories.create_index("name", unique=True, name="category_name_unique") + await categories.create_index("order", name="category_order") diff --git a/backend/app/main.py b/backend/app/main.py index f8c47a1..c039c3e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,17 +1,55 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager from datetime import datetime -app = FastAPI(title="API", version="1.0.0") +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +from app.config import get_settings +from app.database import connect_db, disconnect_db -@app.get("/health") -async def health_check(): - return {"status": "healthy", "timestamp": datetime.now().isoformat()} + +@asynccontextmanager +async def lifespan(app: FastAPI): + """서버 시작/종료 이벤트: MongoDB + Redis 연결 관리""" + await connect_db() + yield + await disconnect_db() + + +def create_app() -> FastAPI: + settings = get_settings() + + app = FastAPI( + title="todos2 API", + description="확장형 할일 관리 애플리케이션 API", + version="1.0.0", + lifespan=lifespan, + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 라우터 등록 + from app.routers import todos, categories, tags, search, dashboard, uploads + app.include_router(todos.router) + app.include_router(categories.router) + app.include_router(tags.router) + app.include_router(search.router) + app.include_router(dashboard.router) + app.include_router(uploads.router) + + # 헬스 체크 + @app.get("/health", tags=["health"]) + async def health_check(): + return {"status": "healthy", "timestamp": datetime.now().isoformat()} + + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..4c13632 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,47 @@ +from app.models.common import PyObjectId, ErrorResponse, PaginatedResponse +from app.models.todo import ( + Priority, + TodoCreate, + TodoUpdate, + TodoResponse, + TodoListResponse, + BatchRequest, + BatchResponse, + ToggleResponse, + TagInfo, + SearchResponse, + DashboardStats, + OverviewStats, + CategoryStats, + PriorityStats, + UpcomingDeadline, +) +from app.models.category import ( + CategoryCreate, + CategoryUpdate, + CategoryResponse, +) + +__all__ = [ + "PyObjectId", + "ErrorResponse", + "PaginatedResponse", + "Priority", + "TodoCreate", + "TodoUpdate", + "TodoResponse", + "TodoListResponse", + "BatchRequest", + "BatchResponse", + "ToggleResponse", + "TagInfo", + "SearchResponse", + "DashboardStats", + "OverviewStats", + "CategoryStats", + "PriorityStats", + "UpcomingDeadline", + "CategoryCreate", + "CategoryUpdate", + "CategoryResponse", +] diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..9c524e6 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,49 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field, field_validator + + +# === Request 스키마 === + + +class CategoryCreate(BaseModel): + """카테고리 생성 요청 (F-007)""" + name: str = Field(..., min_length=1, max_length=50) + color: str = Field("#6B7280", pattern=r"^#[0-9A-Fa-f]{6}$") + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다") + return v + + +class CategoryUpdate(BaseModel): + """카테고리 수정 요청 (F-009) - Partial Update""" + name: Optional[str] = Field(None, min_length=1, max_length=50) + color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + order: Optional[int] = Field(None, ge=0) + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: Optional[str]) -> Optional[str]: + if v is not None: + v = v.strip() + if not v: + raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다") + return v + + +# === Response 스키마 === + + +class CategoryResponse(BaseModel): + """카테고리 응답 (F-008)""" + id: str + name: str + color: str + order: int + todo_count: int = 0 + created_at: datetime diff --git a/backend/app/models/common.py b/backend/app/models/common.py new file mode 100644 index 0000000..e1ddd67 --- /dev/null +++ b/backend/app/models/common.py @@ -0,0 +1,29 @@ +from typing import Annotated, Any +from bson import ObjectId +from pydantic import BaseModel, BeforeValidator + + +def validate_object_id(v: Any) -> str: + """ObjectId <-> str 변환을 위한 유효성 검증기""" + if isinstance(v, ObjectId): + return str(v) + if isinstance(v, str) and ObjectId.is_valid(v): + return v + raise ValueError(f"Invalid ObjectId: {v}") + + +PyObjectId = Annotated[str, BeforeValidator(validate_object_id)] + + +class ErrorResponse(BaseModel): + """표준 에러 응답""" + detail: str + + +class PaginatedResponse(BaseModel): + """페이지네이션 응답 래퍼""" + items: list[Any] + total: int + page: int + limit: int + total_pages: int diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py new file mode 100644 index 0000000..18f8a3b --- /dev/null +++ b/backend/app/models/todo.py @@ -0,0 +1,221 @@ +from datetime import datetime +from enum import Enum +from typing import Optional +from pydantic import BaseModel, Field, field_validator + + +class Priority(str, Enum): + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +# === Request 스키마 === + + +class TodoCreate(BaseModel): + """할일 생성 요청 (F-001)""" + title: str = Field(..., min_length=1, max_length=200) + content: Optional[str] = Field(None, max_length=2000) + category_id: Optional[str] = None + tags: list[str] = Field(default_factory=list, max_length=10) + priority: Priority = Priority.MEDIUM + start_date: Optional[datetime] = None + due_date: Optional[datetime] = None + + @field_validator("title") + @classmethod + def title_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("제목은 공백만으로 구성할 수 없습니다") + return v + + @field_validator("tags") + @classmethod + def normalize_tags(cls, v: list[str]) -> list[str]: + """소문자 정규화, 중복 제거, 공백 trim""" + seen: set[str] = set() + result: list[str] = [] + for tag in v: + tag = tag.strip().lower() + if tag and len(tag) <= 30 and tag not in seen: + seen.add(tag) + result.append(tag) + return result + + @field_validator("category_id") + @classmethod + def validate_category_id(cls, v: Optional[str]) -> Optional[str]: + from bson import ObjectId + if v is not None and not ObjectId.is_valid(v): + raise ValueError("유효하지 않은 카테고리 ID 형식입니다") + return v + + +class TodoUpdate(BaseModel): + """할일 수정 요청 (F-004) - Partial Update""" + title: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = Field(None, max_length=2000) + completed: Optional[bool] = None + category_id: Optional[str] = None + tags: Optional[list[str]] = None + priority: Optional[Priority] = None + start_date: Optional[datetime] = None + due_date: Optional[datetime] = None + + @field_validator("title") + @classmethod + def title_not_blank(cls, v: Optional[str]) -> Optional[str]: + if v is not None: + v = v.strip() + if not v: + raise ValueError("제목은 공백만으로 구성할 수 없습니다") + return v + + @field_validator("tags") + @classmethod + def normalize_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]: + if v is None: + return None + seen: set[str] = set() + result: list[str] = [] + for tag in v: + tag = tag.strip().lower() + if tag and len(tag) <= 30 and tag not in seen: + seen.add(tag) + result.append(tag) + return result + + @field_validator("category_id") + @classmethod + def validate_category_id(cls, v: Optional[str]) -> Optional[str]: + from bson import ObjectId + if v is not None and not ObjectId.is_valid(v): + raise ValueError("유효하지 않은 카테고리 ID 형식입니다") + return v + + +class BatchRequest(BaseModel): + """일괄 작업 요청 (F-019, F-020, F-021)""" + action: str = Field(..., pattern="^(complete|delete|move_category)$") + ids: list[str] = Field(..., min_length=1) + category_id: Optional[str] = None # move_category 시에만 사용 + + @field_validator("ids") + @classmethod + def validate_ids(cls, v: list[str]) -> list[str]: + from bson import ObjectId + for id_str in v: + if not ObjectId.is_valid(id_str): + raise ValueError(f"유효하지 않은 ID 형식: {id_str}") + return v + + +# === Attachment 스키마 === + + +class Attachment(BaseModel): + """첨부파일 메타데이터""" + id: str + filename: str + stored_filename: str + content_type: str + size: int + uploaded_at: datetime + + +# === Response 스키마 === + + +class TodoResponse(BaseModel): + """할일 응답 (카테고리 정보 포함 가능)""" + id: str + title: str + content: Optional[str] = None + completed: bool + priority: Priority + category_id: Optional[str] = None + category_name: Optional[str] = None + category_color: Optional[str] = None + tags: list[str] = [] + start_date: Optional[datetime] = None + due_date: Optional[datetime] = None + attachments: list[Attachment] = [] + created_at: datetime + updated_at: datetime + + +class TodoListResponse(BaseModel): + """할일 목록 페이지네이션 응답 (F-002)""" + items: list[TodoResponse] + total: int + page: int + limit: int + total_pages: int + + +class ToggleResponse(BaseModel): + """완료 토글 응답 (F-006)""" + id: str + completed: bool + + +class BatchResponse(BaseModel): + """일괄 작업 응답 (F-019, F-020, F-021)""" + action: str + processed: int + failed: int + + +class TagInfo(BaseModel): + """태그 정보 (F-013)""" + name: str + count: int + + +class SearchResponse(BaseModel): + """검색 응답 (F-017)""" + items: list[TodoResponse] + total: int + query: str + page: int + limit: int + + +# === 대시보드 통계 모델 === + + +class OverviewStats(BaseModel): + total: int + completed: int + incomplete: int + completion_rate: float + + +class CategoryStats(BaseModel): + category_id: Optional[str] = None + name: str + color: str + count: int + + +class PriorityStats(BaseModel): + high: int + medium: int + low: int + + +class UpcomingDeadline(BaseModel): + id: str + title: str + due_date: datetime + priority: Priority + + +class DashboardStats(BaseModel): + """대시보드 통계 응답 (F-018)""" + overview: OverviewStats + by_category: list[CategoryStats] + by_priority: PriorityStats + upcoming_deadlines: list[UpcomingDeadline] diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..bffe6bb --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,9 @@ +from app.routers import todos, categories, tags, search, dashboard + +__all__ = [ + "todos", + "categories", + "tags", + "search", + "dashboard", +] diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..109669b --- /dev/null +++ b/backend/app/routers/categories.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, Response, status + +from app.database import get_database, get_redis +from app.models.category import ( + CategoryCreate, + CategoryUpdate, + CategoryResponse, +) +from app.services.category_service import CategoryService + +router = APIRouter(prefix="/api/categories", tags=["categories"]) + + +def _get_service( + db=Depends(get_database), + redis=Depends(get_redis), +) -> CategoryService: + return CategoryService(db, redis) + + +@router.get("", response_model=list[CategoryResponse]) +async def list_categories( + service: CategoryService = Depends(_get_service), +): + """F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)""" + return await service.list_categories() + + +@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_category( + data: CategoryCreate, + service: CategoryService = Depends(_get_service), +): + """F-007: 카테고리 생성""" + return await service.create_category(data) + + +@router.put("/{category_id}", response_model=CategoryResponse) +async def update_category( + category_id: str, + data: CategoryUpdate, + service: CategoryService = Depends(_get_service), +): + """F-009: 카테고리 수정""" + return await service.update_category(category_id, data) + + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_category( + category_id: str, + service: CategoryService = Depends(_get_service), +): + """F-010: 카테고리 삭제 (해당 카테고리의 할일 category_id null 처리)""" + await service.delete_category(category_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..cf39c67 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends + +from app.database import get_database, get_redis +from app.models.todo import DashboardStats +from app.services.dashboard_service import DashboardService + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +@router.get("/stats", response_model=DashboardStats) +async def get_dashboard_stats( + db=Depends(get_database), + redis=Depends(get_redis), +): + """F-018: 대시보드 통계 (Redis 캐싱, TTL 60초)""" + service = DashboardService(db, redis) + return await service.get_stats() diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py new file mode 100644 index 0000000..5033790 --- /dev/null +++ b/backend/app/routers/search.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends, Query + +from app.database import get_database +from app.models.todo import SearchResponse +from app.services.search_service import SearchService + +router = APIRouter(prefix="/api/search", tags=["search"]) + + +@router.get("", response_model=SearchResponse) +async def search_todos( + q: str = Query(..., min_length=1, max_length=200, description="검색 키워드"), + page: int = Query(1, ge=1, description="페이지 번호"), + limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"), + db=Depends(get_database), +): + """F-017: 전문 검색 (제목/내용/태그, text score 기준 정렬)""" + service = SearchService(db) + return await service.search(query=q, page=page, limit=limit) diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py new file mode 100644 index 0000000..0bc639a --- /dev/null +++ b/backend/app/routers/tags.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, Depends + +from app.database import get_database +from app.models.todo import TagInfo +from app.services.todo_service import TodoService + +router = APIRouter(prefix="/api/tags", tags=["tags"]) + + +@router.get("", response_model=list[TagInfo]) +async def list_tags( + db=Depends(get_database), +): + """F-013: 사용 중인 태그 목록 조회 (사용 횟수 포함, 내림차순)""" + service = TodoService(db) + return await service.get_tags() diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py new file mode 100644 index 0000000..610e169 --- /dev/null +++ b/backend/app/routers/todos.py @@ -0,0 +1,106 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Response, status + +from app.database import get_database, get_redis +from app.models.todo import ( + TodoCreate, + TodoUpdate, + TodoResponse, + TodoListResponse, + BatchRequest, + BatchResponse, + ToggleResponse, +) +from app.services.todo_service import TodoService + +router = APIRouter(prefix="/api/todos", tags=["todos"]) + + +def _get_service( + db=Depends(get_database), + redis=Depends(get_redis), +) -> TodoService: + return TodoService(db, redis) + + +# 중요: /batch를 /{id} 보다 위에 등록 +@router.post("/batch", response_model=BatchResponse) +async def batch_action( + data: BatchRequest, + service: TodoService = Depends(_get_service), +): + """F-019~F-021: 일괄 작업 (완료/삭제/카테고리 변경)""" + return await service.batch_action(data) + + +@router.get("", response_model=TodoListResponse) +async def list_todos( + page: int = Query(1, ge=1, description="페이지 번호"), + limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"), + completed: Optional[bool] = Query(None, description="완료 상태 필터"), + category_id: Optional[str] = Query(None, description="카테고리 필터"), + priority: Optional[str] = Query(None, description="우선순위 필터 (high/medium/low)"), + tag: Optional[str] = Query(None, description="태그 필터"), + sort: str = Query("created_at", description="정렬 기준 (created_at/due_date/priority)"), + order: str = Query("desc", description="정렬 방향 (asc/desc)"), + service: TodoService = Depends(_get_service), +): + """F-002: 할일 목록 조회 (필터/정렬/페이지네이션)""" + return await service.list_todos( + page=page, + limit=limit, + completed=completed, + category_id=category_id, + priority=priority, + tag=tag, + sort=sort, + order=order, + ) + + +@router.post("", response_model=TodoResponse, status_code=status.HTTP_201_CREATED) +async def create_todo( + data: TodoCreate, + service: TodoService = Depends(_get_service), +): + """F-001: 할일 생성""" + return await service.create_todo(data) + + +@router.get("/{todo_id}", response_model=TodoResponse) +async def get_todo( + todo_id: str, + service: TodoService = Depends(_get_service), +): + """F-003: 할일 상세 조회""" + return await service.get_todo(todo_id) + + +@router.put("/{todo_id}", response_model=TodoResponse) +async def update_todo( + todo_id: str, + data: TodoUpdate, + service: TodoService = Depends(_get_service), +): + """F-004: 할일 수정""" + return await service.update_todo(todo_id, data) + + +@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_todo( + todo_id: str, + service: TodoService = Depends(_get_service), +): + """F-005: 할일 삭제""" + await service.delete_todo(todo_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.patch("/{todo_id}/toggle", response_model=ToggleResponse) +async def toggle_todo( + todo_id: str, + service: TodoService = Depends(_get_service), +): + """F-006: 할일 완료 토글""" + return await service.toggle_todo(todo_id) diff --git a/backend/app/routers/uploads.py b/backend/app/routers/uploads.py new file mode 100644 index 0000000..6896008 --- /dev/null +++ b/backend/app/routers/uploads.py @@ -0,0 +1,56 @@ +from typing import List + +from fastapi import APIRouter, Depends, File, UploadFile, status +from fastapi.responses import FileResponse + +from app.database import get_database +from app.models.todo import Attachment +from app.services.file_service import FileService + +router = APIRouter(prefix="/api/todos", tags=["attachments"]) + + +def _get_service(db=Depends(get_database)) -> FileService: + return FileService(db) + + +@router.post( + "/{todo_id}/attachments", + response_model=list[Attachment], + status_code=status.HTTP_201_CREATED, +) +async def upload_attachments( + todo_id: str, + files: List[UploadFile] = File(...), + service: FileService = Depends(_get_service), +): + """파일 업로드 (최대 5개, 파일당 10MB)""" + return await service.upload_files(todo_id, files) + + +@router.get("/{todo_id}/attachments/{attachment_id}/download") +async def download_attachment( + todo_id: str, + attachment_id: str, + service: FileService = Depends(_get_service), +): + """파일 다운로드""" + file_path, filename = await service.get_file_info(todo_id, attachment_id) + return FileResponse( + path=str(file_path), + filename=filename, + media_type="application/octet-stream", + ) + + +@router.delete( + "/{todo_id}/attachments/{attachment_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_attachment( + todo_id: str, + attachment_id: str, + service: FileService = Depends(_get_service), +): + """첨부파일 삭제""" + await service.delete_attachment(todo_id, attachment_id) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..817bb60 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,11 @@ +from app.services.todo_service import TodoService +from app.services.category_service import CategoryService +from app.services.search_service import SearchService +from app.services.dashboard_service import DashboardService + +__all__ = [ + "TodoService", + "CategoryService", + "SearchService", + "DashboardService", +] diff --git a/backend/app/services/category_service.py b/backend/app/services/category_service.py new file mode 100644 index 0000000..a38e703 --- /dev/null +++ b/backend/app/services/category_service.py @@ -0,0 +1,161 @@ +from datetime import datetime, timezone +from typing import Optional + +from bson import ObjectId +from fastapi import HTTPException +from motor.motor_asyncio import AsyncIOMotorDatabase +import redis.asyncio as aioredis + +from app.models.category import ( + CategoryCreate, + CategoryUpdate, + CategoryResponse, +) + +DASHBOARD_CACHE_KEY = "dashboard:stats" + + +class CategoryService: + """카테고리 CRUD 비즈니스 로직""" + + def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None): + self.collection = db["categories"] + self.todos = db["todos"] + self.redis = redis_client + + async def _invalidate_cache(self): + """대시보드 캐시 무효화""" + if self.redis is not None: + try: + await self.redis.delete(DASHBOARD_CACHE_KEY) + except Exception: + pass + + async def list_categories(self) -> list[CategoryResponse]: + """F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)""" + categories = await self.collection.find().sort("order", 1).to_list(None) + + result = [] + for cat in categories: + cat_id_str = str(cat["_id"]) + # 해당 카테고리의 할일 수 조회 + todo_count = await self.todos.count_documents({"category_id": cat_id_str}) + + result.append(CategoryResponse( + id=cat_id_str, + name=cat["name"], + color=cat.get("color", "#6B7280"), + order=cat.get("order", 0), + todo_count=todo_count, + created_at=cat["created_at"], + )) + + return result + + async def create_category(self, data: CategoryCreate) -> CategoryResponse: + """F-007: 카테고리 생성""" + # 이름 중복 검사 + existing = await self.collection.find_one({"name": data.name}) + if existing: + raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다") + + # order 자동 설정 (현재 최대값 + 1) + max_order_doc = await self.collection.find_one( + {}, + sort=[("order", -1)], + ) + next_order = (max_order_doc["order"] + 1) if max_order_doc and "order" in max_order_doc else 0 + + now = datetime.now(timezone.utc) + doc = { + "name": data.name, + "color": data.color, + "order": next_order, + "created_at": now, + } + + result = await self.collection.insert_one(doc) + doc["_id"] = result.inserted_id + + await self._invalidate_cache() + + return CategoryResponse( + id=str(doc["_id"]), + name=doc["name"], + color=doc["color"], + order=doc["order"], + todo_count=0, + created_at=doc["created_at"], + ) + + async def update_category(self, category_id: str, data: CategoryUpdate) -> CategoryResponse: + """F-009: 카테고리 수정 (Partial Update)""" + if not ObjectId.is_valid(category_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + # 존재 확인 + existing = await self.collection.find_one({"_id": ObjectId(category_id)}) + if not existing: + raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") + + # 변경 필드 추출 + update_data = data.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="수정할 필드가 없습니다") + + # name 변경 시 중복 검사 + if "name" in update_data: + dup = await self.collection.find_one({ + "name": update_data["name"], + "_id": {"$ne": ObjectId(category_id)}, + }) + if dup: + raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다") + + result = await self.collection.find_one_and_update( + {"_id": ObjectId(category_id)}, + {"$set": update_data}, + return_document=True, + ) + + if not result: + raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") + + # todo_count 조회 + cat_id_str = str(result["_id"]) + todo_count = await self.todos.count_documents({"category_id": cat_id_str}) + + await self._invalidate_cache() + + return CategoryResponse( + id=cat_id_str, + name=result["name"], + color=result.get("color", "#6B7280"), + order=result.get("order", 0), + todo_count=todo_count, + created_at=result["created_at"], + ) + + async def delete_category(self, category_id: str) -> None: + """F-010: 카테고리 삭제 + 해당 카테고리의 할일 category_id null 처리""" + if not ObjectId.is_valid(category_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + # 존재 확인 + existing = await self.collection.find_one({"_id": ObjectId(category_id)}) + if not existing: + raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") + + # 해당 카테고리의 모든 todo.category_id -> null 갱신 + await self.todos.update_many( + {"category_id": category_id}, + {"$set": { + "category_id": None, + "updated_at": datetime.now(timezone.utc), + }}, + ) + + # 카테고리 삭제 + await self.collection.delete_one({"_id": ObjectId(category_id)}) + + await self._invalidate_cache() diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py new file mode 100644 index 0000000..fe31938 --- /dev/null +++ b/backend/app/services/dashboard_service.py @@ -0,0 +1,182 @@ +import json +from datetime import datetime, timezone + +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorDatabase +import redis.asyncio as aioredis + +from app.models.todo import ( + DashboardStats, + OverviewStats, + CategoryStats, + PriorityStats, + UpcomingDeadline, +) + +DASHBOARD_CACHE_KEY = "dashboard:stats" +DASHBOARD_CACHE_TTL = 60 # 60초 + + +class DashboardService: + """대시보드 통계 + Redis 캐싱""" + + def __init__(self, db: AsyncIOMotorDatabase, redis_client: aioredis.Redis): + self.todos = db["todos"] + self.categories = db["categories"] + self.redis = redis_client + + async def get_stats(self) -> DashboardStats: + """F-018: 대시보드 통계 + 1. Redis 캐시 확인 + 2. 캐시 히트 -> JSON 파싱 후 반환 + 3. 캐시 미스 -> MongoDB 집계 -> Redis 저장 (TTL 60s) -> 반환 + """ + # Redis 캐시 확인 + try: + cached = await self.redis.get(DASHBOARD_CACHE_KEY) + if cached: + data = json.loads(cached) + return DashboardStats(**data) + except Exception: + pass # Redis 에러 시 DB에서 직접 조회 + + # 캐시 미스: MongoDB 집계 + stats = await self._compute_stats() + + # Redis에 캐싱 + try: + stats_json = stats.model_dump_json() + await self.redis.setex(DASHBOARD_CACHE_KEY, DASHBOARD_CACHE_TTL, stats_json) + except Exception: + pass # Redis 저장 실패는 무시 + + return stats + + async def _compute_stats(self) -> DashboardStats: + """MongoDB 집계 파이프라인으로 통계 계산""" + # 1. Overview (전체/완료/미완료/완료율) + total = await self.todos.count_documents({}) + completed = await self.todos.count_documents({"completed": True}) + incomplete = total - completed + completion_rate = round((completed / total * 100), 1) if total > 0 else 0.0 + + overview = OverviewStats( + total=total, + completed=completed, + incomplete=incomplete, + completion_rate=completion_rate, + ) + + # 2. By Category (카테고리별 할일 분포) + by_category = await self._compute_by_category() + + # 3. By Priority (우선순위별 현황) + by_priority = await self._compute_by_priority() + + # 4. Upcoming Deadlines (마감 임박 할일 상위 5개) + upcoming_deadlines = await self._compute_upcoming_deadlines() + + return DashboardStats( + overview=overview, + by_category=by_category, + by_priority=by_priority, + upcoming_deadlines=upcoming_deadlines, + ) + + async def _compute_by_category(self) -> list[CategoryStats]: + """카테고리별 할일 분포 집계""" + pipeline = [ + {"$group": { + "_id": "$category_id", + "count": {"$sum": 1}, + }}, + {"$sort": {"count": -1}}, + ] + + results = await self.todos.aggregate(pipeline).to_list(None) + + # 카테고리 정보 조회 + cat_ids = set() + for r in results: + if r["_id"]: + try: + cat_ids.add(ObjectId(r["_id"])) + except Exception: + pass + + cat_map: dict[str, dict] = {} + if cat_ids: + async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}): + cat_map[str(cat["_id"])] = cat + + category_stats = [] + for r in results: + cat_id = r["_id"] + if cat_id and cat_id in cat_map: + cat = cat_map[cat_id] + category_stats.append(CategoryStats( + category_id=cat_id, + name=cat["name"], + color=cat.get("color", "#6B7280"), + count=r["count"], + )) + else: + category_stats.append(CategoryStats( + category_id=None, + name="미분류", + color="#9CA3AF", + count=r["count"], + )) + + return category_stats + + async def _compute_by_priority(self) -> PriorityStats: + """우선순위별 현황 집계""" + pipeline = [ + {"$group": { + "_id": "$priority", + "count": {"$sum": 1}, + }}, + ] + + results = await self.todos.aggregate(pipeline).to_list(None) + + priority_map = {"high": 0, "medium": 0, "low": 0} + for r in results: + if r["_id"] in priority_map: + priority_map[r["_id"]] = r["count"] + + return PriorityStats( + high=priority_map["high"], + medium=priority_map["medium"], + low=priority_map["low"], + ) + + async def _compute_upcoming_deadlines(self) -> list[UpcomingDeadline]: + """마감 임박 할일 상위 5개 (미완료, 마감일 있는 것, 마감일 오름차순)""" + now = datetime.now(timezone.utc) + + cursor = self.todos.find({ + "completed": False, + "due_date": {"$ne": None, "$gte": now}, + }).sort("due_date", 1).limit(5) + + docs = await cursor.to_list(5) + + deadlines = [] + for doc in docs: + deadlines.append(UpcomingDeadline( + id=str(doc["_id"]), + title=doc["title"], + due_date=doc["due_date"], + priority=doc["priority"], + )) + + return deadlines + + async def invalidate_cache(self) -> None: + """캐시 무효화: 할일 CUD 작업 후 호출""" + try: + await self.redis.delete(DASHBOARD_CACHE_KEY) + except Exception: + pass diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py new file mode 100644 index 0000000..f5bdebb --- /dev/null +++ b/backend/app/services/file_service.py @@ -0,0 +1,154 @@ +import shutil +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from bson import ObjectId +from fastapi import HTTPException, UploadFile +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.config import get_settings +from app.models.todo import Attachment + + +class FileService: + """파일 업로드/다운로드/삭제 서비스""" + + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["todos"] + self.settings = get_settings() + self.upload_dir = Path(self.settings.upload_dir) + + def _ensure_todo_dir(self, todo_id: str) -> Path: + todo_dir = self.upload_dir / todo_id + todo_dir.mkdir(parents=True, exist_ok=True) + return todo_dir + + async def upload_files( + self, todo_id: str, files: list[UploadFile] + ) -> list[Attachment]: + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + todo = await self.collection.find_one({"_id": ObjectId(todo_id)}) + if not todo: + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + existing_count = len(todo.get("attachments", [])) + if existing_count + len(files) > self.settings.max_files_per_todo: + raise HTTPException( + status_code=400, + detail=f"최대 {self.settings.max_files_per_todo}개의 파일만 첨부할 수 있습니다", + ) + + todo_dir = self._ensure_todo_dir(todo_id) + attachments: list[Attachment] = [] + saved_paths: list[Path] = [] + + try: + for file in files: + content = await file.read() + if len(content) > self.settings.max_file_size: + raise HTTPException( + status_code=400, + detail=f"파일 '{file.filename}'이 최대 크기(10MB)를 초과합니다", + ) + + file_id = str(uuid.uuid4()) + ext = Path(file.filename or "").suffix + stored_filename = f"{file_id}{ext}" + file_path = todo_dir / stored_filename + + with open(file_path, "wb") as f: + f.write(content) + saved_paths.append(file_path) + + attachments.append( + Attachment( + id=file_id, + filename=file.filename or "unknown", + stored_filename=stored_filename, + content_type=file.content_type or "application/octet-stream", + size=len(content), + uploaded_at=datetime.now(timezone.utc), + ) + ) + + await self.collection.update_one( + {"_id": ObjectId(todo_id)}, + { + "$push": { + "attachments": { + "$each": [a.model_dump() for a in attachments] + } + }, + "$set": {"updated_at": datetime.now(timezone.utc)}, + }, + ) + except HTTPException: + for p in saved_paths: + p.unlink(missing_ok=True) + raise + except Exception: + for p in saved_paths: + p.unlink(missing_ok=True) + raise HTTPException(status_code=500, detail="파일 업로드에 실패했습니다") + + return attachments + + async def delete_attachment(self, todo_id: str, attachment_id: str) -> None: + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + todo = await self.collection.find_one({"_id": ObjectId(todo_id)}) + if not todo: + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + attachment = None + for att in todo.get("attachments", []): + if att["id"] == attachment_id: + attachment = att + break + + if not attachment: + raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다") + + file_path = self.upload_dir / todo_id / attachment["stored_filename"] + if file_path.exists(): + file_path.unlink() + + await self.collection.update_one( + {"_id": ObjectId(todo_id)}, + { + "$pull": {"attachments": {"id": attachment_id}}, + "$set": {"updated_at": datetime.now(timezone.utc)}, + }, + ) + + async def get_file_info( + self, todo_id: str, attachment_id: str + ) -> tuple[Path, str]: + """파일 경로와 원본 파일명을 반환""" + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + todo = await self.collection.find_one({"_id": ObjectId(todo_id)}) + if not todo: + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + for att in todo.get("attachments", []): + if att["id"] == attachment_id: + file_path = self.upload_dir / todo_id / att["stored_filename"] + if not file_path.exists(): + raise HTTPException( + status_code=404, detail="파일이 디스크에서 찾을 수 없습니다" + ) + return file_path, att["filename"] + + raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다") + + async def delete_all_todo_files(self, todo_id: str) -> None: + """Todo 삭제 시 해당 Todo의 모든 파일 삭제""" + todo_dir = self.upload_dir / todo_id + if todo_dir.exists(): + shutil.rmtree(todo_dir) diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 0000000..0bc590a --- /dev/null +++ b/backend/app/services/search_service.py @@ -0,0 +1,92 @@ +import math + +from bson import ObjectId +from fastapi import HTTPException +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.models.todo import TodoResponse, SearchResponse + + +class SearchService: + """MongoDB text index 기반 검색 로직""" + + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["todos"] + self.categories = db["categories"] + + async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]: + """여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다.""" + # 카테고리 ID 수집 + cat_ids = set() + for doc in todo_docs: + cat_id = doc.get("category_id") + if cat_id: + try: + cat_ids.add(ObjectId(cat_id)) + except Exception: + pass + + # 카테고리 일괄 조회 + cat_map: dict[str, dict] = {} + if cat_ids: + async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}): + cat_map[str(cat["_id"])] = cat + + # TodoResponse 변환 + result = [] + for doc in todo_docs: + cat_id = str(doc["category_id"]) if doc.get("category_id") else None + cat = cat_map.get(cat_id) if cat_id else None + + result.append(TodoResponse( + id=str(doc["_id"]), + title=doc["title"], + content=doc.get("content"), + completed=doc["completed"], + priority=doc["priority"], + category_id=cat_id, + category_name=cat["name"] if cat else None, + category_color=cat["color"] if cat else None, + tags=doc.get("tags", []), + due_date=doc.get("due_date"), + created_at=doc["created_at"], + updated_at=doc["updated_at"], + )) + + return result + + async def search(self, query: str, page: int = 1, limit: int = 20) -> SearchResponse: + """F-017: 전문 검색 + MongoDB text index를 활용한 전문 검색, text score 기준 정렬, 페이지네이션 + """ + if not query or not query.strip(): + raise HTTPException(status_code=422, detail="검색어를 입력해주세요") + + query = query.strip() + + # MongoDB text search + search_filter = {"$text": {"$search": query}} + + # 총 개수 조회 + total = await self.collection.count_documents(search_filter) + + # text score 기준 정렬 + 페이지네이션 + skip = (page - 1) * limit + cursor = self.collection.find( + search_filter, + {"score": {"$meta": "textScore"}}, + ).sort( + [("score", {"$meta": "textScore"})] + ).skip(skip).limit(limit) + + docs = await cursor.to_list(limit) + + items = await self._populate_categories_bulk(docs) + + return SearchResponse( + items=items, + total=total, + query=query, + page=page, + limit=limit, + ) diff --git a/backend/app/services/todo_service.py b/backend/app/services/todo_service.py new file mode 100644 index 0000000..545ceda --- /dev/null +++ b/backend/app/services/todo_service.py @@ -0,0 +1,396 @@ +import math +from datetime import datetime, timezone +from typing import Optional + +from bson import ObjectId +from fastapi import HTTPException +from motor.motor_asyncio import AsyncIOMotorDatabase +import redis.asyncio as aioredis + +from app.models.todo import ( + Priority, + TodoCreate, + TodoUpdate, + TodoResponse, + TodoListResponse, + BatchRequest, + BatchResponse, + ToggleResponse, + TagInfo, +) + +# 우선순위 정렬용 매핑 (높음이 먼저 오도록) +PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2} + +DASHBOARD_CACHE_KEY = "dashboard:stats" + + +class TodoService: + """할일 CRUD + 일괄 작업 + 태그 집계 비즈니스 로직""" + + def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None): + self.collection = db["todos"] + self.categories = db["categories"] + self.redis = redis_client + + async def _invalidate_cache(self): + """대시보드 캐시 무효화""" + if self.redis is not None: + try: + await self.redis.delete(DASHBOARD_CACHE_KEY) + except Exception: + pass # Redis 오류 시 무시 (캐시 무효화 실패는 치명적이지 않음) + + async def _populate_category(self, todo_doc: dict) -> TodoResponse: + """Todo 문서에 카테고리 정보를 추가하여 TodoResponse를 반환한다.""" + category_name = None + category_color = None + + cat_id = todo_doc.get("category_id") + if cat_id: + category = await self.categories.find_one({"_id": ObjectId(cat_id)}) + if category: + category_name = category["name"] + category_color = category["color"] + + return TodoResponse( + id=str(todo_doc["_id"]), + title=todo_doc["title"], + content=todo_doc.get("content"), + completed=todo_doc["completed"], + priority=todo_doc["priority"], + category_id=str(todo_doc["category_id"]) if todo_doc.get("category_id") else None, + category_name=category_name, + category_color=category_color, + tags=todo_doc.get("tags", []), + start_date=todo_doc.get("start_date"), + due_date=todo_doc.get("due_date"), + attachments=todo_doc.get("attachments", []), + created_at=todo_doc["created_at"], + updated_at=todo_doc["updated_at"], + ) + + async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]: + """여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다.""" + # 카테고리 ID 수집 + cat_ids = set() + for doc in todo_docs: + cat_id = doc.get("category_id") + if cat_id: + try: + cat_ids.add(ObjectId(cat_id)) + except Exception: + pass + + # 카테고리 일괄 조회 + cat_map: dict[str, dict] = {} + if cat_ids: + async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}): + cat_map[str(cat["_id"])] = cat + + # TodoResponse 변환 + result = [] + for doc in todo_docs: + cat_id = str(doc["category_id"]) if doc.get("category_id") else None + cat = cat_map.get(cat_id) if cat_id else None + + result.append(TodoResponse( + id=str(doc["_id"]), + title=doc["title"], + content=doc.get("content"), + completed=doc["completed"], + priority=doc["priority"], + category_id=cat_id, + category_name=cat["name"] if cat else None, + category_color=cat["color"] if cat else None, + tags=doc.get("tags", []), + start_date=doc.get("start_date"), + due_date=doc.get("due_date"), + attachments=doc.get("attachments", []), + created_at=doc["created_at"], + updated_at=doc["updated_at"], + )) + + return result + + # --- CRUD --- + + async def create_todo(self, data: TodoCreate) -> TodoResponse: + """F-001: 할일 생성""" + # category_id 존재 검증 + if data.category_id: + cat = await self.categories.find_one({"_id": ObjectId(data.category_id)}) + if not cat: + raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") + + now = datetime.now(timezone.utc) + doc = { + "title": data.title, + "content": data.content, + "completed": False, + "priority": data.priority.value, + "category_id": data.category_id, + "tags": data.tags, + "start_date": data.start_date, + "due_date": data.due_date, + "created_at": now, + "updated_at": now, + } + + result = await self.collection.insert_one(doc) + doc["_id"] = result.inserted_id + + await self._invalidate_cache() + + return await self._populate_category(doc) + + async def list_todos( + self, + page: int = 1, + limit: int = 20, + completed: Optional[bool] = None, + category_id: Optional[str] = None, + priority: Optional[str] = None, + tag: Optional[str] = None, + sort: str = "created_at", + order: str = "desc", + ) -> TodoListResponse: + """F-002: 할일 목록 조회 (필터, 정렬, 페이지네이션)""" + # 필터 조건 구성 (AND) + query: dict = {} + if completed is not None: + query["completed"] = completed + if category_id: + query["category_id"] = category_id + if priority: + query["priority"] = priority + if tag: + query["tags"] = tag + + # 정렬 조건 구성 + sort_direction = 1 if order == "asc" else -1 + if sort == "priority": + # priority 정렬 시 매핑 사용 (MongoDB에선 문자열 정렬이므로 보조 필드 필요) + # 대안: aggregation pipeline 사용 + pipeline = [] + if query: + pipeline.append({"$match": query}) + pipeline.append({ + "$addFields": { + "priority_order": { + "$switch": { + "branches": [ + {"case": {"$eq": ["$priority", "high"]}, "then": 0}, + {"case": {"$eq": ["$priority", "medium"]}, "then": 1}, + {"case": {"$eq": ["$priority", "low"]}, "then": 2}, + ], + "default": 1, + } + } + } + }) + pipeline.append({"$sort": {"priority_order": sort_direction, "created_at": -1}}) + + # 총 개수 조회 + count_pipeline = list(pipeline) + [{"$count": "total"}] + count_result = await self.collection.aggregate(count_pipeline).to_list(1) + total = count_result[0]["total"] if count_result else 0 + + # 페이지네이션 + skip = (page - 1) * limit + pipeline.append({"$skip": skip}) + pipeline.append({"$limit": limit}) + pipeline.append({"$project": {"priority_order": 0}}) + + docs = await self.collection.aggregate(pipeline).to_list(limit) + else: + # 일반 정렬 + sort_field = sort if sort in ("created_at", "due_date") else "created_at" + sort_spec = [(sort_field, sort_direction)] + + total = await self.collection.count_documents(query) + skip = (page - 1) * limit + cursor = self.collection.find(query).sort(sort_spec).skip(skip).limit(limit) + docs = await cursor.to_list(limit) + + total_pages = math.ceil(total / limit) if limit > 0 else 0 + items = await self._populate_categories_bulk(docs) + + return TodoListResponse( + items=items, + total=total, + page=page, + limit=limit, + total_pages=total_pages, + ) + + async def get_todo(self, todo_id: str) -> TodoResponse: + """F-003: 할일 상세 조회""" + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + doc = await self.collection.find_one({"_id": ObjectId(todo_id)}) + if not doc: + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + return await self._populate_category(doc) + + async def update_todo(self, todo_id: str, data: TodoUpdate) -> TodoResponse: + """F-004: 할일 수정 (Partial Update)""" + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + # 변경 필드만 추출 + update_data = data.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="수정할 필드가 없습니다") + + # category_id 존재 검증 + if "category_id" in update_data and update_data["category_id"] is not None: + cat = await self.categories.find_one({"_id": ObjectId(update_data["category_id"])}) + if not cat: + raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") + + # priority enum -> string 변환 + if "priority" in update_data and update_data["priority"] is not None: + update_data["priority"] = update_data["priority"].value if hasattr(update_data["priority"], "value") else update_data["priority"] + + update_data["updated_at"] = datetime.now(timezone.utc) + + result = await self.collection.find_one_and_update( + {"_id": ObjectId(todo_id)}, + {"$set": update_data}, + return_document=True, + ) + + if not result: + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + await self._invalidate_cache() + + return await self._populate_category(result) + + async def delete_todo(self, todo_id: str) -> None: + """F-005: 할일 삭제""" + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + result = await self.collection.delete_one({"_id": ObjectId(todo_id)}) + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + # 첨부파일 삭제 + from app.services.file_service import FileService + file_service = FileService(self.collection.database) + await file_service.delete_all_todo_files(todo_id) + + await self._invalidate_cache() + + async def toggle_todo(self, todo_id: str) -> ToggleResponse: + """F-006: 완료 토글""" + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + doc = await self.collection.find_one({"_id": ObjectId(todo_id)}) + if not doc: + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + new_completed = not doc["completed"] + await self.collection.update_one( + {"_id": ObjectId(todo_id)}, + {"$set": { + "completed": new_completed, + "updated_at": datetime.now(timezone.utc), + }}, + ) + + await self._invalidate_cache() + + return ToggleResponse(id=todo_id, completed=new_completed) + + # --- 일괄 작업 --- + + async def batch_action(self, data: BatchRequest) -> BatchResponse: + """F-019~F-021: 일괄 작업 분기""" + if data.action == "complete": + return await self._batch_complete(data.ids) + elif data.action == "delete": + return await self._batch_delete(data.ids) + elif data.action == "move_category": + return await self._batch_move_category(data.ids, data.category_id) + else: + raise HTTPException(status_code=400, detail="지원하지 않는 작업입니다") + + async def _batch_complete(self, ids: list[str]) -> BatchResponse: + """F-019: 일괄 완료""" + object_ids = [ObjectId(id_str) for id_str in ids] + result = await self.collection.update_many( + {"_id": {"$in": object_ids}}, + {"$set": { + "completed": True, + "updated_at": datetime.now(timezone.utc), + }}, + ) + + await self._invalidate_cache() + + return BatchResponse( + action="complete", + processed=result.modified_count, + failed=len(ids) - result.modified_count, + ) + + async def _batch_delete(self, ids: list[str]) -> BatchResponse: + """F-020: 일괄 삭제""" + object_ids = [ObjectId(id_str) for id_str in ids] + result = await self.collection.delete_many({"_id": {"$in": object_ids}}) + + await self._invalidate_cache() + + return BatchResponse( + action="delete", + processed=result.deleted_count, + failed=len(ids) - result.deleted_count, + ) + + async def _batch_move_category(self, ids: list[str], category_id: Optional[str]) -> BatchResponse: + """F-021: 일괄 카테고리 변경""" + # category_id 검증 (null이 아닌 경우) + if category_id is not None: + if not ObjectId.is_valid(category_id): + raise HTTPException(status_code=422, detail="유효하지 않은 카테고리 ID 형식입니다") + cat = await self.categories.find_one({"_id": ObjectId(category_id)}) + if not cat: + raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") + + object_ids = [ObjectId(id_str) for id_str in ids] + result = await self.collection.update_many( + {"_id": {"$in": object_ids}}, + {"$set": { + "category_id": category_id, + "updated_at": datetime.now(timezone.utc), + }}, + ) + + await self._invalidate_cache() + + return BatchResponse( + action="move_category", + processed=result.modified_count, + failed=len(ids) - result.modified_count, + ) + + # --- 태그 --- + + async def get_tags(self) -> list[TagInfo]: + """F-013: 태그 목록 조회 (사용 횟수 포함, 사용 횟수 내림차순)""" + pipeline = [ + {"$unwind": "$tags"}, + {"$group": {"_id": "$tags", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + {"$project": {"name": "$_id", "count": 1, "_id": 0}}, + ] + + results = await self.collection.aggregate(pipeline).to_list(None) + return [TagInfo(name=r["name"], count=r["count"]) for r in results] diff --git a/backend/requirements.txt b/backend/requirements.txt index cfc3b01..2551eee 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,9 @@ -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -motor>=3.3.0 -pydantic>=2.5.0 -aioredis>=2.0.0 -python-dotenv>=1.0.0 +fastapi>=0.104 +uvicorn[standard]>=0.24 +motor>=3.3 +pymongo>=4.9,<4.10 +pydantic>=2.5 +pydantic-settings>=2.1 +redis>=5.0 +python-dotenv>=1.0 +python-multipart>=0.0.6 diff --git a/docker-compose.yml b/docker-compose.yml index 5112ee8..ba12b0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,8 @@ services: - MONGODB_URL=mongodb://${MONGO_USER:-admin}:${MONGO_PASSWORD:-password123}@mongodb:27017/ - DB_NAME=${DB_NAME:-app_db} - REDIS_URL=redis://redis:6379 + volumes: + - upload_data:/app/uploads depends_on: mongodb: condition: service_healthy @@ -77,6 +79,7 @@ services: volumes: mongodb_data: redis_data: + upload_data: networks: app-network: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5cb0c0e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,2648 @@ +# todos2 -- 시스템 아키텍처 + +> 버전: 1.0.0 | 작성일: 2026-02-10 | 상태: 설계 완료 +> 기반 문서: PLAN.md v1.0.0, FEATURE_SPEC.md v1.0.0, SCREEN_DESIGN.md v1.0.0 + +--- + +## 1. 시스템 개요 + +### 1.1 아키텍처 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Docker Compose │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────────────┐ │ +│ │ Frontend (Next.js) │ │ Backend (FastAPI) │ │ +│ │ :3000 │──────>│ :8000 │ │ +│ │ │ REST │ │ │ +│ │ ┌────────────────┐ │ API │ ┌────────┐ ┌───────────┐ │ │ +│ │ │ App Router │ │ │ │Routers │─>│ Services │ │ │ +│ │ │ (pages) │ │ │ └────────┘ └─────┬─────┘ │ │ +│ │ ├────────────────┤ │ │ │ │ │ +│ │ │ Components │ │ │ ┌─────▼─────┐ │ │ +│ │ ├────────────────┤ │ │ │ Database │ │ │ +│ │ │ Tanstack Query │ │ │ │ Layer │ │ │ +│ │ │ + Zustand │ │ │ └─────┬─────┘ │ │ +│ │ └────────────────┘ │ │ │ │ │ +│ └──────────────────────┘ └────────────────────┼────────┘ │ +│ │ │ +│ ┌──────────────────────┐ ┌────────────────────▼────────┐ │ +│ │ Redis 7 │<──────│ MongoDB 7 │ │ +│ │ :6379 │ cache │ :27017 │ │ +│ │ │ aside │ │ │ +│ │ - 대시보드 통계 캐시 │ │ - todos 컬렉션 │ │ +│ │ - TTL 60s │ │ - categories 컬렉션 │ │ +│ └──────────────────────┘ └─────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 기술 스택 요약 + +| 레이어 | 기술 | 버전 | 역할 | +|--------|------|------|------| +| **프론트엔드** | Next.js (App Router) | 15 | SSR/CSR 하이브리드 렌더링 | +| | TypeScript | 5.x | 타입 안정성 | +| | Tailwind CSS | 4.x | 유틸리티 기반 스타일링 | +| | shadcn/ui | latest | UI 컴포넌트 라이브러리 | +| | Recharts | 2.x | 대시보드 차트 | +| | Tanstack Query | 5.x | 서버 상태 관리 (API 캐싱) | +| | Zustand | 5.x | 클라이언트 UI 상태 관리 | +| **백엔드** | Python | 3.11 | 런타임 | +| | FastAPI | >= 0.104 | REST API 프레임워크 | +| | Motor | >= 3.6 | MongoDB 비동기 드라이버 | +| | Pydantic v2 | >= 2.5 | 데이터 검증/직렬화 | +| | Uvicorn | >= 0.24 | ASGI 서버 | +| | aioredis | >= 2.0 | Redis 비동기 클라이언트 | +| **데이터베이스** | MongoDB | 7.0 | 메인 데이터 저장소 | +| | Redis | 7.0 | 캐싱 레이어 | +| **인프라** | Docker Compose | 3.8 | 로컬 개발 환경 | + +### 1.3 데이터 흐름 + +``` +[사용자] → [브라우저] + │ + ▼ +[Next.js Frontend :3000] + │ fetch (REST API) + ▼ +[FastAPI Backend :8000] + │ + ├──▶ [Router] → 요청 검증, 라우팅 + │ │ + │ ▼ + ├──▶ [Service] → 비즈니스 로직 + │ │ + │ ├──▶ [MongoDB :27017] → 데이터 읽기/쓰기 + │ │ + │ └──▶ [Redis :6379] → 캐시 읽기/쓰기 (대시보드 통계) + │ + └──▶ [Response] → JSON 직렬화 → 클라이언트 반환 +``` + +--- + +## 2. 백엔드 아키텍처 + +### 2.1 디렉토리 구조 + +``` +backend/ +├── Dockerfile +├── requirements.txt +└── app/ + ├── __init__.py + ├── main.py # FastAPI 앱 팩토리, 미들웨어, 이벤트 훅 + ├── config.py # Settings (pydantic-settings) + ├── database.py # MongoDB(Motor) + Redis(aioredis) 연결 + ├── models/ + │ ├── __init__.py + │ ├── todo.py # Todo 도메인 모델 + Request/Response 스키마 + │ ├── category.py # Category 도메인 모델 + Request/Response 스키마 + │ └── common.py # 공통 모델 (PyObjectId, 페이지네이션, 에러 응답) + ├── routers/ + │ ├── __init__.py + │ ├── todos.py # /api/todos CRUD + toggle + batch + │ ├── categories.py # /api/categories CRUD + │ ├── tags.py # /api/tags 목록 조회 + │ ├── search.py # /api/search 전문 검색 + │ └── dashboard.py # /api/dashboard/stats 통계 + └── services/ + ├── __init__.py + ├── todo_service.py # 할일 비즈니스 로직 + ├── category_service.py # 카테고리 비즈니스 로직 + ├── search_service.py # 검색 비즈니스 로직 + └── dashboard_service.py# 대시보드 통계 + Redis 캐싱 +``` + +> **참고**: PLAN.md에서 `models/tag.py`, `routers/batch.py`, `services/tag_service.py`가 포함되어 있으나, 설계 원칙 7에 따라 태그는 별도 컬렉션 없이 Todo 문서 내 배열로 관리하므로 다음과 같이 조정한다: +> - `models/tag.py` -> 삭제. 태그 관련 타입은 `models/todo.py` 내에 정의 +> - `routers/batch.py` -> 삭제. 일괄 작업은 `routers/todos.py`의 `/api/todos/batch` 엔드포인트로 통합 +> - `services/tag_service.py` -> 삭제. 태그 집계 로직은 `services/todo_service.py`에 포함 + +### 2.2 레이어 구조 (Router -> Service -> Database) + +``` +┌─────────────────────────────────────────────────┐ +│ Router Layer │ +│ - HTTP 요청/응답 처리 │ +│ - 경로 파라미터/쿼리 파라미터 추출 │ +│ - Request Body -> Pydantic 모델 검증 │ +│ - Service 호출 후 Response 모델로 직렬화 │ +│ - HTTP 상태 코드 결정 │ +└───────────────────────┬─────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Service Layer │ +│ - 비즈니스 로직 집중 │ +│ - 데이터 검증 (카테고리 존재 여부 등) │ +│ - 태그 정규화 (소문자 변환, 중복 제거) │ +│ - 캐시 무효화 (Redis) │ +│ - 트랜잭션 처리 (카테고리 삭제 시 todo 일괄 갱신) │ +└───────────────────────┬─────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Database Layer │ +│ - Motor (async) 기반 MongoDB 접근 │ +│ - aioredis 기반 Redis 접근 │ +│ - 컬렉션 참조, 인덱스 생성 │ +│ - 연결 풀 관리 (lifespan event) │ +└─────────────────────────────────────────────────┘ +``` + +**의존성 주입 패턴**: + +```python +# app/database.py +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +import redis.asyncio as aioredis + +class Database: + client: AsyncIOMotorClient | None = None + db: AsyncIOMotorDatabase | None = None + redis: aioredis.Redis | None = None + +db = Database() + +def get_database() -> AsyncIOMotorDatabase: + if db.db is None: # 주의: `if not db.db:` 사용 금지 (pymongo 4.9.x NotImplementedError) + raise RuntimeError("Database not initialized") + return db.db + +def get_redis() -> aioredis.Redis: + if db.redis is None: + raise RuntimeError("Redis not initialized") + return db.redis +``` + +```python +# Router에서 Service 사용 예시 +from fastapi import APIRouter, Depends +from app.database import get_database, get_redis +from app.services.todo_service import TodoService + +router = APIRouter(prefix="/api/todos", tags=["todos"]) + +@router.get("") +async def list_todos( + page: int = 1, + limit: int = 20, + database=Depends(get_database), +): + service = TodoService(database) + return await service.list_todos(page=page, limit=limit) +``` + +### 2.3 모델 정의 (Pydantic v2) + +#### 2.3.1 공통 모델 (`app/models/common.py`) + +```python +from datetime import datetime +from typing import Annotated, Any +from bson import ObjectId +from pydantic import BaseModel, Field, BeforeValidator + +# ObjectId <-> str 변환을 위한 커스텀 타입 +def validate_object_id(v: Any) -> str: + if isinstance(v, ObjectId): + return str(v) + if isinstance(v, str) and ObjectId.is_valid(v): + return v + raise ValueError(f"Invalid ObjectId: {v}") + +PyObjectId = Annotated[str, BeforeValidator(validate_object_id)] + + +class ErrorResponse(BaseModel): + """표준 에러 응답""" + detail: str + + +class PaginatedResponse(BaseModel): + """페이지네이션 응답 래퍼""" + items: list[Any] + total: int + page: int + limit: int + total_pages: int +``` + +#### 2.3.2 Todo 모델 (`app/models/todo.py`) + +```python +from datetime import datetime +from enum import Enum +from typing import Optional +from pydantic import BaseModel, Field, field_validator +from app.models.common import PyObjectId + + +class Priority(str, Enum): + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +# === 도메인 모델 (MongoDB 문서 대응) === + +class TodoInDB(BaseModel): + """MongoDB에 저장되는 Todo 문서 스키마""" + id: PyObjectId = Field(alias="_id") + title: str = Field(..., min_length=1, max_length=200) + content: Optional[str] = Field(None, max_length=2000) + completed: bool = False + priority: Priority = Priority.MEDIUM + category_id: Optional[PyObjectId] = None + tags: list[str] = Field(default_factory=list) + due_date: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + model_config = { + "populate_by_name": True, # alias와 필드명 모두 허용 + "json_encoders": { + datetime: lambda v: v.isoformat(), + }, + } + + +# === Request 스키마 === + +class TodoCreate(BaseModel): + """할일 생성 요청 (F-001)""" + title: str = Field(..., min_length=1, max_length=200) + content: Optional[str] = Field(None, max_length=2000) + category_id: Optional[str] = None + tags: list[str] = Field(default_factory=list, max_length=10) + priority: Priority = Priority.MEDIUM + due_date: Optional[datetime] = None + + @field_validator("title") + @classmethod + def title_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("제목은 공백만으로 구성할 수 없습니다") + return v + + @field_validator("tags") + @classmethod + def normalize_tags(cls, v: list[str]) -> list[str]: + # 소문자 정규화, 중복 제거, 공백 trim + seen = set() + result = [] + for tag in v: + tag = tag.strip().lower() + if tag and len(tag) <= 30 and tag not in seen: + seen.add(tag) + result.append(tag) + return result + + @field_validator("category_id") + @classmethod + def validate_category_id(cls, v: Optional[str]) -> Optional[str]: + from bson import ObjectId + if v is not None and not ObjectId.is_valid(v): + raise ValueError("유효하지 않은 카테고리 ID 형식입니다") + return v + + +class TodoUpdate(BaseModel): + """할일 수정 요청 (F-004) - Partial Update""" + title: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = Field(None, max_length=2000) + category_id: Optional[str] = None # None = 필드 미포함, "null" 문자열 사용 X + tags: Optional[list[str]] = None + priority: Optional[Priority] = None + due_date: Optional[datetime] = None + + @field_validator("title") + @classmethod + def title_not_blank(cls, v: Optional[str]) -> Optional[str]: + if v is not None: + v = v.strip() + if not v: + raise ValueError("제목은 공백만으로 구성할 수 없습니다") + return v + + @field_validator("tags") + @classmethod + def normalize_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]: + if v is None: + return None + seen = set() + result = [] + for tag in v: + tag = tag.strip().lower() + if tag and len(tag) <= 30 and tag not in seen: + seen.add(tag) + result.append(tag) + return result + + +class BatchRequest(BaseModel): + """일괄 작업 요청 (F-019, F-020, F-021)""" + action: str = Field(..., pattern="^(complete|delete|move_category)$") + ids: list[str] = Field(..., min_length=1) + category_id: Optional[str] = None # move_category 시에만 사용 + + @field_validator("ids") + @classmethod + def validate_ids(cls, v: list[str]) -> list[str]: + from bson import ObjectId + for id_str in v: + if not ObjectId.is_valid(id_str): + raise ValueError(f"유효하지 않은 ID 형식: {id_str}") + return v + + +# === Response 스키마 === + +class TodoResponse(BaseModel): + """할일 응답 (카테고리 정보 포함 가능)""" + id: str + title: str + content: Optional[str] = None + completed: bool + priority: Priority + category_id: Optional[str] = None + category_name: Optional[str] = None # populate 시 + category_color: Optional[str] = None # populate 시 + tags: list[str] = [] + due_date: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + +class TodoListResponse(BaseModel): + """할일 목록 페이지네이션 응답 (F-002)""" + items: list[TodoResponse] + total: int + page: int + limit: int + total_pages: int + + +class ToggleResponse(BaseModel): + """완료 토글 응답 (F-006)""" + id: str + completed: bool + + +class BatchResponse(BaseModel): + """일괄 작업 응답 (F-019, F-020, F-021)""" + action: str + processed: int + failed: int + + +class TagInfo(BaseModel): + """태그 정보 (F-013)""" + name: str + count: int +``` + +#### 2.3.3 Category 모델 (`app/models/category.py`) + +```python +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field, field_validator +from app.models.common import PyObjectId +import re + + +# === 도메인 모델 === + +class CategoryInDB(BaseModel): + """MongoDB에 저장되는 Category 문서 스키마""" + id: PyObjectId = Field(alias="_id") + name: str = Field(..., min_length=1, max_length=50) + color: str = "#6B7280" + order: int = 0 + created_at: datetime + + model_config = { + "populate_by_name": True, + } + + +# === Request 스키마 === + +class CategoryCreate(BaseModel): + """카테고리 생성 요청 (F-007)""" + name: str = Field(..., min_length=1, max_length=50) + color: str = Field("#6B7280", pattern=r"^#[0-9A-Fa-f]{6}$") + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다") + return v + + +class CategoryUpdate(BaseModel): + """카테고리 수정 요청 (F-009) - Partial Update""" + name: Optional[str] = Field(None, min_length=1, max_length=50) + color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + order: Optional[int] = Field(None, ge=0) + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: Optional[str]) -> Optional[str]: + if v is not None: + v = v.strip() + if not v: + raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다") + return v + + +# === Response 스키마 === + +class CategoryResponse(BaseModel): + """카테고리 응답 (F-008)""" + id: str + name: str + color: str + order: int + todo_count: int = 0 # 해당 카테고리에 속한 할일 수 + created_at: datetime +``` + +### 2.4 API 엔드포인트 상세 + +> **중요**: FastAPI 라우트 순서 -- `/batch` 같은 고정 경로를 `/{id}` 패턴보다 위에 배치해야 한다. 그렇지 않으면 "batch" 문자열이 `{id}` 파라미터로 매칭된다. + +#### 2.4.1 할일 API (`/api/todos`) + +| # | Method | Path | 기능 ID | 설명 | Request Body | Response | 상태 코드 | +|---|--------|------|---------|------|-------------|----------|-----------| +| 1 | POST | `/api/todos` | F-001 | 할일 생성 | `TodoCreate` | `TodoResponse` | 201 / 404 / 422 | +| 2 | GET | `/api/todos` | F-002 | 할일 목록 조회 | Query Params | `TodoListResponse` | 200 / 422 | +| 3 | POST | `/api/todos/batch` | F-019~21 | 일괄 작업 | `BatchRequest` | `BatchResponse` | 200 / 404 / 422 | +| 4 | GET | `/api/todos/{id}` | F-003 | 할일 상세 조회 | - | `TodoResponse` | 200 / 404 / 422 | +| 5 | PUT | `/api/todos/{id}` | F-004 | 할일 수정 | `TodoUpdate` | `TodoResponse` | 200 / 404 / 422 | +| 6 | DELETE | `/api/todos/{id}` | F-005 | 할일 삭제 | - | - | 204 / 404 | +| 7 | PATCH | `/api/todos/{id}/toggle` | F-006 | 완료 토글 | - | `ToggleResponse` | 200 / 404 | + +> 라우트 등록 순서: `POST /batch` -> `GET /` -> `POST /` -> `GET /{id}` -> `PUT /{id}` -> `DELETE /{id}` -> `PATCH /{id}/toggle` + +**할일 목록 쿼리 파라미터 (F-002)**: + +| 파라미터 | 타입 | 기본값 | 설명 | +|---------|------|--------|------| +| `page` | int | 1 | 페이지 번호 (>= 1) | +| `limit` | int | 20 | 페이지당 항목 수 (1~100) | +| `completed` | bool \| None | None | 완료 상태 필터 | +| `category_id` | str \| None | None | 카테고리 필터 | +| `priority` | str \| None | None | 우선순위 필터 (high/medium/low) | +| `tag` | str \| None | None | 태그 필터 | +| `sort` | str | "created_at" | 정렬 기준 (created_at/due_date/priority) | +| `order` | str | "desc" | 정렬 방향 (asc/desc) | + +#### 2.4.2 카테고리 API (`/api/categories`) + +| # | Method | Path | 기능 ID | 설명 | Request Body | Response | 상태 코드 | +|---|--------|------|---------|------|-------------|----------|-----------| +| 1 | GET | `/api/categories` | F-008 | 카테고리 목록 | - | `list[CategoryResponse]` | 200 | +| 2 | POST | `/api/categories` | F-007 | 카테고리 생성 | `CategoryCreate` | `CategoryResponse` | 201 / 409 / 422 | +| 3 | PUT | `/api/categories/{id}` | F-009 | 카테고리 수정 | `CategoryUpdate` | `CategoryResponse` | 200 / 404 / 409 | +| 4 | DELETE | `/api/categories/{id}` | F-010 | 카테고리 삭제 | - | - | 204 / 404 | + +#### 2.4.3 태그 API (`/api/tags`) + +| # | Method | Path | 기능 ID | 설명 | Response | 상태 코드 | +|---|--------|------|---------|------|----------|-----------| +| 1 | GET | `/api/tags` | F-013 | 태그 목록 (사용 횟수 포함) | `list[TagInfo]` | 200 | + +#### 2.4.4 검색 API (`/api/search`) + +| # | Method | Path | 기능 ID | 설명 | Response | 상태 코드 | +|---|--------|------|---------|------|----------|-----------| +| 1 | GET | `/api/search` | F-017 | 전문 검색 | `SearchResponse` | 200 / 422 | + +**검색 쿼리 파라미터**: + +| 파라미터 | 타입 | 기본값 | 설명 | +|---------|------|--------|------| +| `q` | str | (필수) | 검색 키워드 (1~200자) | +| `page` | int | 1 | 페이지 번호 | +| `limit` | int | 20 | 페이지당 항목 수 | + +#### 2.4.5 대시보드 API (`/api/dashboard`) + +| # | Method | Path | 기능 ID | 설명 | Response | 상태 코드 | +|---|--------|------|---------|------|----------|-----------| +| 1 | GET | `/api/dashboard/stats` | F-018 | 대시보드 통계 | `DashboardStats` | 200 | + +### 2.5 서비스 레이어 + +#### 2.5.1 TodoService (`app/services/todo_service.py`) + +```python +class TodoService: + """할일 CRUD + 일괄 작업 + 태그 집계 비즈니스 로직""" + + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["todos"] + self.categories = db["categories"] + + # --- CRUD --- + async def create_todo(self, data: TodoCreate) -> TodoResponse: + """F-001: 할일 생성 + 1. category_id 존재 검증 + 2. tags 정규화 (소문자, 중복 제거) -> TodoCreate validator에서 처리됨 + 3. created_at, updated_at = UTC now + 4. completed = False + 5. MongoDB insert -> 생성된 문서 반환 + """ + + async def list_todos( + self, page: int, limit: int, + completed: bool | None, category_id: str | None, + priority: str | None, tag: str | None, + sort: str, order: str, + ) -> TodoListResponse: + """F-002: 할일 목록 조회 + 1. 필터 조건 구성 (AND 조합) + 2. 정렬 조건 구성 (priority 정렬 시 high=0, medium=1, low=2 매핑) + 3. skip/limit 페이지네이션 + 4. 총 개수(total) 조회 + 5. 카테고리 정보 populate (lookup 또는 후처리) + """ + + async def get_todo(self, todo_id: str) -> TodoResponse: + """F-003: 할일 상세 조회 + 1. ObjectId 유효성 검증 + 2. 문서 조회 (없으면 404) + 3. 카테고리 정보 populate + """ + + async def update_todo(self, todo_id: str, data: TodoUpdate) -> TodoResponse: + """F-004: 할일 수정 (Partial Update) + 1. data.model_dump(exclude_unset=True) 로 변경 필드만 추출 + 2. category_id 변경 시 존재 검증 + 3. updated_at 갱신 + 4. $set 연산자로 업데이트 + """ + + async def delete_todo(self, todo_id: str) -> None: + """F-005: 할일 삭제 + 1. 문서 존재 확인 (없으면 404) + 2. 물리 삭제 (delete_one) + """ + + async def toggle_todo(self, todo_id: str) -> ToggleResponse: + """F-006: 완료 토글 + 1. 현재 completed 값 조회 + 2. 반대값으로 $set 업데이트 + 3. updated_at 갱신 + """ + + # --- 일괄 작업 --- + async def batch_action(self, data: BatchRequest) -> BatchResponse: + """F-019~F-021: 일괄 작업 분기 + - action == "complete": batch_complete() + - action == "delete": batch_delete() + - action == "move_category": batch_move_category() + """ + + async def batch_complete(self, ids: list[str]) -> BatchResponse: + """F-019: 일괄 완료 + 1. ids -> ObjectId 리스트 변환 + 2. update_many($set: {completed: true, updated_at: now}) + 3. modified_count 반환 + """ + + async def batch_delete(self, ids: list[str]) -> BatchResponse: + """F-020: 일괄 삭제 + 1. ids -> ObjectId 리스트 변환 + 2. delete_many({_id: {$in: ids}}) + 3. deleted_count 반환 + """ + + async def batch_move_category(self, ids: list[str], category_id: str | None) -> BatchResponse: + """F-021: 일괄 카테고리 변경 + 1. category_id 존재 검증 (null이 아닌 경우) + 2. update_many($set: {category_id: ..., updated_at: now}) + 3. modified_count 반환 + """ + + # --- 태그 --- + async def get_tags(self) -> list[TagInfo]: + """F-013: 태그 목록 조회 + 1. aggregate pipeline: + $unwind: "$tags" + $group: {_id: "$tags", count: {$sum: 1}} + $sort: {count: -1} + $project: {name: "$_id", count: 1, _id: 0} + """ +``` + +#### 2.5.2 CategoryService (`app/services/category_service.py`) + +```python +class CategoryService: + """카테고리 CRUD 비즈니스 로직""" + + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["categories"] + self.todos = db["todos"] + + async def list_categories(self) -> list[CategoryResponse]: + """F-008: 카테고리 목록 조회 + 1. order 기준 오름차순 정렬 + 2. 각 카테고리별 todo_count 집계 (aggregate 또는 후처리) + """ + + async def create_category(self, data: CategoryCreate) -> CategoryResponse: + """F-007: 카테고리 생성 + 1. name 중복 검사 (unique index로도 보호) + 2. order = 현재 최대값 + 1 + 3. created_at = UTC now + """ + + async def update_category(self, category_id: str, data: CategoryUpdate) -> CategoryResponse: + """F-009: 카테고리 수정 + 1. 존재 확인 (404) + 2. name 변경 시 중복 검사 (409) + 3. Partial Update + """ + + async def delete_category(self, category_id: str) -> None: + """F-010: 카테고리 삭제 + 1. 존재 확인 (404) + 2. 해당 카테고리의 모든 todo.category_id -> null 갱신 + 3. 카테고리 문서 삭제 + """ +``` + +#### 2.5.3 SearchService (`app/services/search_service.py`) + +```python +class SearchService: + """MongoDB text index 기반 검색 로직""" + + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["todos"] + self.categories = db["categories"] + + async def search(self, query: str, page: int, limit: int) -> SearchResponse: + """F-017: 전문 검색 + 1. MongoDB text search: {$text: {$search: query}} + 2. text score 기준 정렬: {$meta: "textScore"} + 3. 페이지네이션 적용 + 4. 카테고리 정보 populate + 5. SearchResponse 구성 (items, total, query, page, limit) + """ +``` + +#### 2.5.4 DashboardService (`app/services/dashboard_service.py`) + +```python +import json +import redis.asyncio as aioredis + +DASHBOARD_CACHE_KEY = "dashboard:stats" +DASHBOARD_CACHE_TTL = 60 # 60초 + +class DashboardService: + """대시보드 통계 + Redis 캐싱""" + + def __init__(self, db: AsyncIOMotorDatabase, redis_client: aioredis.Redis): + self.todos = db["todos"] + self.categories = db["categories"] + self.redis = redis_client + + async def get_stats(self) -> DashboardStats: + """F-018: 대시보드 통계 + 1. Redis 캐시 확인 + 2. 캐시 히트 -> JSON 파싱 후 반환 + 3. 캐시 미스 -> MongoDB 집계 -> Redis 저장 (TTL 60s) -> 반환 + """ + + async def _compute_stats(self) -> DashboardStats: + """MongoDB 집계 파이프라인으로 통계 계산 + - overview: total, completed, incomplete, completion_rate + - by_category: aggregate group by category_id + lookup + - by_priority: aggregate group by priority + - upcoming_deadlines: due_date 오름차순, 미완료, 미래 날짜, limit 5 + """ + + async def invalidate_cache(self) -> None: + """캐시 무효화: 할일 CUD 작업 후 호출""" + await self.redis.delete(DASHBOARD_CACHE_KEY) +``` + +### 2.6 데이터베이스 설정 + +#### 2.6.1 MongoDB 연결 (Motor async) + +```python +# app/database.py +from contextlib import asynccontextmanager +from motor.motor_asyncio import AsyncIOMotorClient +import redis.asyncio as aioredis +from app.config import get_settings + +class Database: + client: AsyncIOMotorClient | None = None + db = None # AsyncIOMotorDatabase + redis: aioredis.Redis | None = None + +db = Database() + +async def connect_db(): + """MongoDB + Redis 연결 초기화""" + settings = get_settings() + + # MongoDB + db.client = AsyncIOMotorClient(settings.mongodb_url) + db.db = db.client[settings.mongodb_database] + + # 인덱스 생성 + await create_indexes(db.db) + + # Redis + db.redis = aioredis.from_url( + settings.redis_url, + encoding="utf-8", + decode_responses=True, + ) + +async def disconnect_db(): + """연결 종료""" + if db.client is not None: + db.client.close() + if db.redis is not None: + await db.redis.close() + +async def create_indexes(database): + """컬렉션 인덱스 생성""" + todos = database["todos"] + categories = database["categories"] + + # todos 인덱스 + await todos.create_index( + [("title", "text"), ("content", "text"), ("tags", "text")], + name="text_search_index", + weights={"title": 10, "tags": 5, "content": 1}, + ) + await todos.create_index([("category_id", 1), ("created_at", -1)], name="category_created") + await todos.create_index([("completed", 1), ("created_at", -1)], name="completed_created") + await todos.create_index([("priority", 1), ("created_at", -1)], name="priority_created") + await todos.create_index([("tags", 1)], name="tags") + await todos.create_index([("due_date", 1)], name="due_date") + await todos.create_index([("completed", 1), ("due_date", 1)], name="completed_due_date") + + # categories 인덱스 + await categories.create_index("name", unique=True, name="category_name_unique") + await categories.create_index("order", name="category_order") +``` + +#### 2.6.2 FastAPI 앱 팩토리 (`app/main.py`) + +```python +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import get_settings +from app.database import connect_db, disconnect_db + +@asynccontextmanager +async def lifespan(app: FastAPI): + """서버 시작/종료 이벤트""" + await connect_db() + yield + await disconnect_db() + +def create_app() -> FastAPI: + settings = get_settings() + + app = FastAPI( + title="todos2 API", + description="확장형 할일 관리 애플리케이션 API", + version="1.0.0", + lifespan=lifespan, + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=[settings.frontend_url, "http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 라우터 등록 + from app.routers import todos, categories, tags, search, dashboard + app.include_router(todos.router) + app.include_router(categories.router) + app.include_router(tags.router) + app.include_router(search.router) + app.include_router(dashboard.router) + + return app + +app = create_app() +``` + +#### 2.6.3 설정 (`app/config.py`) + +```python +from pydantic_settings import BaseSettings +from functools import lru_cache + +class Settings(BaseSettings): + mongodb_url: str = "mongodb://mongodb:27017" + mongodb_database: str = "todos2" + redis_url: str = "redis://redis:6379" + frontend_url: str = "http://localhost:3000" + + class Config: + env_file = ".env" + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +#### 2.6.4 컬렉션 및 인덱스 설계 + +**todos 컬렉션**: + +| 인덱스 이름 | 필드 | 타입 | 용도 | +|------------|------|------|------| +| `text_search_index` | title, content, tags | text (가중치: 10, 1, 5) | 전문 검색 (F-017) | +| `category_created` | category_id ASC, created_at DESC | compound | 카테고리별 목록 조회 | +| `completed_created` | completed ASC, created_at DESC | compound | 완료 상태별 목록 조회 | +| `priority_created` | priority ASC, created_at DESC | compound | 우선순위별 목록 조회 | +| `tags` | tags ASC | multikey | 태그별 필터링 | +| `due_date` | due_date ASC | single | 마감일 정렬 | +| `completed_due_date` | completed ASC, due_date ASC | compound | 대시보드 마감 임박 조회 | + +**categories 컬렉션**: + +| 인덱스 이름 | 필드 | 타입 | 용도 | +|------------|------|------|------| +| `category_name_unique` | name | unique | 이름 중복 방지 | +| `category_order` | order ASC | single | 순서 정렬 | + +#### 2.6.5 Redis 캐싱 전략 + +| 키 | 데이터 | TTL | 무효화 시점 | +|----|--------|-----|------------| +| `dashboard:stats` | DashboardStats JSON | 60초 | 할일 생성/수정/삭제/토글/일괄 작업 후 | + +**캐시 무효화 패턴**: Cache-Aside (Lazy Invalidation) +- 쓰기 작업(CUD) 시 `DashboardService.invalidate_cache()` 호출 +- 읽기 시 캐시 미스면 MongoDB 집계 후 Redis에 저장 + +```python +# TodoService의 CUD 메서드에서 캐시 무효화 +async def create_todo(self, data: TodoCreate) -> TodoResponse: + # ... 할일 생성 로직 ... + await self.dashboard_service.invalidate_cache() + return result +``` + +--- + +## 3. 프론트엔드 아키텍처 + +### 3.1 디렉토리 구조 + +``` +frontend/ +├── Dockerfile +├── package.json +├── next.config.ts +├── tsconfig.json +├── postcss.config.mjs +├── components.json # shadcn/ui 설정 +├── public/ +│ └── favicon.ico +└── src/ + ├── app/ # App Router 페이지 + │ ├── layout.tsx # 루트 레이아웃 (MainLayout, Providers) + │ ├── page.tsx # P-001 대시보드 (/) + │ ├── todos/ + │ │ ├── page.tsx # P-002 할일 목록 (/todos) + │ │ └── [id]/ + │ │ └── page.tsx # P-003 할일 상세/편집 (/todos/[id]) + │ ├── categories/ + │ │ └── page.tsx # P-004 카테고리 관리 (/categories) + │ └── search/ + │ └── page.tsx # P-005 검색 결과 (/search) + ├── components/ + │ ├── ui/ # shadcn/ui 원본 컴포넌트 (자동 생성) + │ │ ├── button.tsx + │ │ ├── input.tsx + │ │ ├── select.tsx + │ │ ├── checkbox.tsx + │ │ ├── badge.tsx + │ │ ├── card.tsx + │ │ ├── dialog.tsx + │ │ ├── dropdown-menu.tsx + │ │ ├── popover.tsx + │ │ ├── calendar.tsx + │ │ ├── toast.tsx + │ │ ├── skeleton.tsx + │ │ ├── pagination.tsx + │ │ └── command.tsx # 태그 자동완성용 + │ ├── layout/ + │ │ ├── Header.tsx # 로고 + 검색바 + 알림 영역 + │ │ ├── Sidebar.tsx # 네비게이션 + 카테고리 + 태그 목록 + │ │ └── MainLayout.tsx # Header + Sidebar + main content 구성 + │ ├── todos/ + │ │ ├── TodoCard.tsx # 개별 할일 행 (체크박스, 제목, 태그, 마감일 등) + │ │ ├── TodoForm.tsx # 할일 생성/수정 모달 폼 + │ │ ├── TodoList.tsx # 할일 카드 리스트 (loading/empty/error/data) + │ │ ├── TodoFilter.tsx # 상태/우선순위/정렬 필터 바 + │ │ ├── TodoDetailForm.tsx# 할일 상세/편집 폼 (P-003) + │ │ └── BatchActions.tsx # 일괄 작업 바 (완료/삭제/카테고리 변경) + │ ├── categories/ + │ │ ├── CategoryList.tsx # 카테고리 목록 (P-004) + │ │ ├── CategoryItem.tsx # 개별 카테고리 행 + │ │ ├── CategoryForm.tsx # 카테고리 생성/수정 인라인 폼 + │ │ ├── CategorySelect.tsx# 카테고리 드롭다운 (TodoForm용) + │ │ └── ColorPicker.tsx # 색상 선택기 + │ ├── todos/shared/ + │ │ ├── PrioritySelect.tsx# 우선순위 드롭다운 (색상 구분) + │ │ ├── PriorityBadge.tsx # 우선순위 뱃지 표시 + │ │ ├── DueDateBadge.tsx # 마감일 뱃지 (임박/초과 알림 포함) + │ │ └── DatePicker.tsx # 달력 마감일 선택 + │ ├── tags/ + │ │ ├── TagBadge.tsx # 태그 뱃지 (클릭 시 필터링) + │ │ ├── TagSelect.tsx # 태그 다중 선택 (자동완성) + │ │ └── TagInput.tsx # 태그 입력 + 자동완성 + 뱃지 (P-003) + │ ├── dashboard/ + │ │ ├── StatsCards.tsx # 전체/완료/미완료/완료율 카드 4개 + │ │ ├── CompletionChart.tsx # (StatsCards에 통합 가능, 별도 분리 가능) + │ │ ├── CategoryChart.tsx # 카테고리별 도넛 차트 (Recharts) + │ │ ├── PriorityChart.tsx # 우선순위별 막대 차트 (Recharts) + │ │ └── UpcomingDeadlines.tsx # 마감 임박 할일 Top 5 리스트 + │ └── search/ + │ ├── SearchBar.tsx # 헤더 내 검색 입력 (Header에서 사용) + │ ├── SearchResults.tsx # 검색 결과 리스트 + │ └── SearchResultItem.tsx # 개별 검색 결과 (제목 하이라이트) + ├── hooks/ + │ ├── useTodos.ts # Tanstack Query: 할일 CRUD + 일괄 작업 + │ ├── useCategories.ts # Tanstack Query: 카테고리 CRUD + │ ├── useTags.ts # Tanstack Query: 태그 목록 조회 + │ ├── useDashboard.ts # Tanstack Query: 대시보드 통계 + │ └── useSearch.ts # Tanstack Query: 검색 + ├── lib/ + │ ├── api.ts # fetch wrapper (base URL, 에러 처리) + │ └── utils.ts # 유틸리티 (날짜 포맷, 마감일 상태 계산 등) + ├── store/ + │ └── uiStore.ts # Zustand: 사이드바/필터/선택 등 UI 상태 + └── types/ + └── index.ts # 전역 TypeScript 타입 정의 +``` + +### 3.2 라우팅 (App Router) + +| 경로 | 페이지 ID | 파일 | 설명 | 렌더링 | +|------|----------|------|------|--------| +| `/` | P-001 | `app/page.tsx` | 대시보드 | CSR (Tanstack Query) | +| `/todos` | P-002 | `app/todos/page.tsx` | 할일 목록 | CSR (필터/페이지네이션) | +| `/todos/[id]` | P-003 | `app/todos/[id]/page.tsx` | 할일 상세/편집 | CSR | +| `/categories` | P-004 | `app/categories/page.tsx` | 카테고리 관리 | CSR | +| `/search` | P-005 | `app/search/page.tsx` | 검색 결과 | CSR (쿼리 파라미터 `?q=`) | + +> 모든 페이지는 CSR(Client Side Rendering)로 처리한다. 데이터 패칭은 Tanstack Query가 담당하며, Next.js App Router는 레이아웃 구조와 라우팅만 제공한다. + +**루트 레이아웃 (`app/layout.tsx`)**: + +```tsx +// app/layout.tsx +import { Providers } from "@/components/providers"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} +``` + +`Providers` 컴포넌트에서 `QueryClientProvider`를 감싼다 (`"use client"` 필수). + +### 3.3 컴포넌트 계층 + +#### 3.3.1 레이아웃 컴포넌트 + +``` +RootLayout (app/layout.tsx) +└── Providers (QueryClientProvider + children) + └── MainLayout + ├── Header + │ ├── Logo ("todos2") + │ ├── SearchBar + │ └── Notification Area + ├── Sidebar + │ ├── Navigation Links + │ │ ├── 대시보드 (/) + │ │ ├── 할일 목록 (/todos) + │ │ └── 카테고리 관리 (/categories) + │ ├── Category List (사이드바용) + │ │ └── CategoryItem (이름, 색상 dot, todo_count) + │ └── Popular Tags + │ └── TagBadge (클릭 -> /todos?tag=xxx) + └── Main Content Area + └── {children} (각 페이지 콘텐츠) +``` + +#### 3.3.2 페이지별 컴포넌트 트리 + +**P-001 대시보드 (`/`)**: +``` +DashboardPage +├── StatsCards +│ ├── Card: 전체 할일 수 +│ ├── Card: 완료 수 +│ ├── Card: 미완료 수 +│ └── Card: 완료율 (%) +├── CategoryChart (Recharts PieChart / 도넛) +├── PriorityChart (Recharts BarChart / 가로 막대) +└── UpcomingDeadlines + └── DeadlineItem (제목, D-day, 우선순위) +``` + +**P-002 할일 목록 (`/todos`)**: +``` +TodoListPage +├── TodoFilter +│ ├── Select: 상태 (전체/완료/미완료) +│ ├── Select: 우선순위 (전체/높음/중간/낮음) +│ └── Select: 정렬 (생성일/마감일/우선순위 x ASC/DESC) +├── Button: "+ 새 할일" +├── BatchActions (선택된 항목 >= 1일 때 표시) +│ ├── Label: "N개 선택됨" +│ ├── Button: 일괄 완료 +│ ├── Button: 카테고리 변경 (DropdownMenu) +│ └── Button: 일괄 삭제 +├── TodoList +│ └── TodoCard (반복) +│ ├── Checkbox: 선택 (일괄 작업용) +│ ├── Checkbox: 완료 토글 +│ ├── Title (완료 시 취소선) +│ ├── CategoryBadge (색상 + 이름) +│ ├── TagBadge[] (클릭 -> 필터링) +│ ├── PriorityBadge (색상 구분) +│ ├── DueDateBadge (D-day, 임박/초과 알림) +│ ├── Button: 수정 +│ └── Button: 삭제 +├── Pagination +└── TodoForm (Modal, 생성/수정 모드) + ├── Input: 제목 + ├── Textarea: 내용 + ├── CategorySelect + ├── PrioritySelect + ├── DatePicker: 마감일 + └── TagSelect: 태그 +``` + +**P-003 할일 상세/편집 (`/todos/[id]`)**: +``` +TodoDetailPage +├── Breadcrumb: 할일 목록 > {제목} +└── TodoDetailForm + ├── Input: 제목 + ├── Textarea: 내용 + ├── CategorySelect + ├── PrioritySelect + ├── DatePicker + ├── TagInput (자동완성 + 뱃지) + ├── Button: 저장 + ├── Button: 취소 + └── Button: 삭제 +``` + +**P-004 카테고리 관리 (`/categories`)**: +``` +CategoryPage +├── Header: "카테고리 관리" +├── Button: "+ 새 카테고리" +├── CategoryForm (인라인, 생성 모드) +│ ├── Input: 이름 +│ ├── ColorPicker: 색상 +│ └── Button: 추가 +└── CategoryList + └── CategoryItem (반복) + ├── ColorDot (카테고리 색상) + ├── Name + ├── Label: "N개 할일" + ├── Button: 수정 -> CategoryForm (인라인, 수정 모드) + └── Button: 삭제 -> 확인 다이얼로그 +``` + +**P-005 검색 결과 (`/search?q=xxx`)**: +``` +SearchPage +├── Label: '"{query}" 검색 결과 (N건)' +├── SearchResults +│ └── SearchResultItem (반복) +│ ├── Title (검색어 하이라이트) +│ ├── Content snippet (검색어 하이라이트) +│ ├── CategoryBadge +│ └── DueDateBadge +├── Pagination +└── EmptyState ("검색 결과가 없습니다...") +``` + +### 3.4 상태 관리 + +#### 3.4.1 Tanstack Query: 서버 상태 + +모든 API 데이터는 Tanstack Query로 관리한다. 쿼리 키 설계: + +```typescript +// hooks/useTodos.ts +export const todoKeys = { + all: ["todos"] as const, + lists: () => [...todoKeys.all, "list"] as const, + list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const, + details: () => [...todoKeys.all, "detail"] as const, + detail: (id: string) => [...todoKeys.details(), id] as const, +}; + +export const categoryKeys = { + all: ["categories"] as const, + list: () => [...categoryKeys.all, "list"] as const, +}; + +export const tagKeys = { + all: ["tags"] as const, + list: () => [...tagKeys.all, "list"] as const, +}; + +export const dashboardKeys = { + all: ["dashboard"] as const, + stats: () => [...dashboardKeys.all, "stats"] as const, +}; + +export const searchKeys = { + all: ["search"] as const, + results: (query: string, page: number) => [...searchKeys.all, query, page] as const, +}; +``` + +**캐싱 설정**: + +```typescript +// QueryClient 기본 설정 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1분 (기본) + gcTime: 1000 * 60 * 5, // 5분 (가비지 컬렉션) + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); +``` + +| 쿼리 | staleTime | gcTime | refetchOnMount | +|------|-----------|--------|----------------| +| 할일 목록 | 30s | 5m | always | +| 할일 상세 | 30s | 5m | always | +| 카테고리 목록 | 60s | 10m | true | +| 태그 목록 | 60s | 10m | true | +| 대시보드 통계 | 60s | 5m | always | +| 검색 결과 | 0s (항상 fresh) | 5m | always | + +**Mutation 후 캐시 무효화**: + +```typescript +// hooks/useTodos.ts +export function useCreateTodo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: TodoCreate) => apiClient.post("/api/todos", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); + queryClient.invalidateQueries({ queryKey: tagKeys.all }); + queryClient.invalidateQueries({ queryKey: dashboardKeys.all }); + queryClient.invalidateQueries({ queryKey: categoryKeys.all }); // todo_count 갱신 + }, + }); +} + +export function useToggleTodo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => apiClient.patch(`/api/todos/${id}/toggle`), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); + queryClient.invalidateQueries({ queryKey: todoKeys.detail(id) }); + queryClient.invalidateQueries({ queryKey: dashboardKeys.all }); + }, + }); +} +``` + +#### 3.4.2 Zustand: 클라이언트 UI 상태 + +```typescript +// store/uiStore.ts +import { create } from "zustand"; + +interface TodoFilters { + completed?: boolean; + category_id?: string; + priority?: "high" | "medium" | "low"; + tag?: string; + sort: string; + order: "asc" | "desc"; + page: number; + limit: number; +} + +interface UIState { + // 사이드바 + sidebarOpen: boolean; + toggleSidebar: () => void; + + // 할일 목록 필터 + filters: TodoFilters; + setFilter: (key: keyof TodoFilters, value: any) => void; + resetFilters: () => void; + + // 일괄 선택 + selectedIds: string[]; + toggleSelect: (id: string) => void; + selectAll: (ids: string[]) => void; + clearSelection: () => void; + + // 모달 + todoFormOpen: boolean; + todoFormMode: "create" | "edit"; + editingTodoId: string | null; + openTodoForm: (mode: "create" | "edit", todoId?: string) => void; + closeTodoForm: () => void; +} + +const DEFAULT_FILTERS: TodoFilters = { + sort: "created_at", + order: "desc", + page: 1, + limit: 20, +}; + +export const useUIStore = create((set) => ({ + // 사이드바 + sidebarOpen: true, + toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), + + // 필터 + filters: { ...DEFAULT_FILTERS }, + setFilter: (key, value) => + set((s) => ({ + filters: { ...s.filters, [key]: value, page: key === "page" ? value : 1 }, + })), + resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), + + // 일괄 선택 + selectedIds: [], + toggleSelect: (id) => + set((s) => ({ + selectedIds: s.selectedIds.includes(id) + ? s.selectedIds.filter((i) => i !== id) + : [...s.selectedIds, id], + })), + selectAll: (ids) => set({ selectedIds: ids }), + clearSelection: () => set({ selectedIds: [] }), + + // 모달 + todoFormOpen: false, + todoFormMode: "create", + editingTodoId: null, + openTodoForm: (mode, todoId) => + set({ todoFormOpen: true, todoFormMode: mode, editingTodoId: todoId ?? null }), + closeTodoForm: () => + set({ todoFormOpen: false, editingTodoId: null }), +})); +``` + +### 3.5 API 클라이언트 + +```typescript +// lib/api.ts +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +interface ApiError { + detail: string; + status: number; +} + +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + private async request( + method: string, + path: string, + options?: { + body?: unknown; + params?: Record; + } + ): Promise { + const url = new URL(`${this.baseUrl}${path}`); + + // 쿼리 파라미터 추가 (undefined 제외) + if (options?.params) { + Object.entries(options.params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + }); + } + + const res = await fetch(url.toString(), { + method, + headers: { + "Content-Type": "application/json", + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + // 204 No Content + if (res.status === 204) { + return undefined as T; + } + + // 에러 응답 처리 + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "알 수 없는 오류가 발생했습니다" })); + throw { + detail: error.detail || `HTTP ${res.status} Error`, + status: res.status, + } as ApiError; + } + + return res.json(); + } + + // 편의 메서드 + get(path: string, params?: Record) { + return this.request("GET", path, { params }); + } + + post(path: string, body?: unknown) { + return this.request("POST", path, { body }); + } + + put(path: string, body?: unknown) { + return this.request("PUT", path, { body }); + } + + patch(path: string, body?: unknown) { + return this.request("PATCH", path, { body }); + } + + delete(path: string) { + return this.request("DELETE", path); + } +} + +export const apiClient = new ApiClient(API_BASE_URL); +``` + +### 3.6 타입 정의 + +```typescript +// types/index.ts + +// === Enums === +export type Priority = "high" | "medium" | "low"; +export type SortField = "created_at" | "due_date" | "priority"; +export type SortOrder = "asc" | "desc"; +export type BatchAction = "complete" | "delete" | "move_category"; + +// === Todo === +export interface Todo { + id: string; + title: string; + content?: string | null; + completed: boolean; + priority: Priority; + category_id?: string | null; + category_name?: string | null; + category_color?: string | null; + tags: string[]; + due_date?: string | null; // ISO 8601 + created_at: string; // ISO 8601 + updated_at: string; // ISO 8601 +} + +export interface TodoCreate { + title: string; + content?: string; + category_id?: string | null; + tags?: string[]; + priority?: Priority; + due_date?: string | null; +} + +export interface TodoUpdate { + title?: string; + content?: string | null; + category_id?: string | null; + tags?: string[]; + priority?: Priority; + due_date?: string | null; +} + +export interface TodoListResponse { + items: Todo[]; + total: number; + page: number; + limit: number; + total_pages: number; +} + +export interface ToggleResponse { + id: string; + completed: boolean; +} + +// === Batch === +export interface BatchRequest { + action: BatchAction; + ids: string[]; + category_id?: string | null; +} + +export interface BatchResponse { + action: string; + processed: number; + failed: number; +} + +// === Category === +export interface Category { + id: string; + name: string; + color: string; + order: number; + todo_count: number; + created_at: string; +} + +export interface CategoryCreate { + name: string; + color?: string; +} + +export interface CategoryUpdate { + name?: string; + color?: string; + order?: number; +} + +// === Tag === +export interface TagInfo { + name: string; + count: number; +} + +// === Search === +export interface SearchResponse { + items: Todo[]; + total: number; + query: string; + page: number; + limit: number; +} + +// === Dashboard === +export interface DashboardOverview { + total: number; + completed: number; + incomplete: number; + completion_rate: number; +} + +export interface CategoryStat { + category_id: string | null; + name: string; + color: string; + count: number; +} + +export interface PriorityStat { + high: number; + medium: number; + low: number; +} + +export interface UpcomingDeadline { + id: string; + title: string; + due_date: string; + priority: Priority; +} + +export interface DashboardStats { + overview: DashboardOverview; + by_category: CategoryStat[]; + by_priority: PriorityStat; + upcoming_deadlines: UpcomingDeadline[]; +} + +// === Filters === +export interface TodoFilters { + completed?: boolean; + category_id?: string; + priority?: Priority; + tag?: string; + sort: SortField; + order: SortOrder; + page: number; + limit: number; +} + +// === Error === +export interface ApiError { + detail: string; + status: number; +} +``` + +--- + +## 4. 데이터베이스 설계 + +### 4.1 컬렉션 + +#### todos 컬렉션 스키마 + +```javascript +// MongoDB 문서 구조 +{ + "_id": ObjectId("..."), + "title": "API 문서 작성", // string, 필수, 1~200자 + "content": "Swagger UI 기반 API 문서를 작성...", // string, 선택, 최대 2000자 + "completed": false, // boolean, 기본 false + "priority": "high", // enum: "high"|"medium"|"low", 기본 "medium" + "category_id": ObjectId("..."), // ObjectId|null, 카테고리 참조 + "tags": ["긴급", "문서"], // string[], 인라인 태그, 최대 10개 + "due_date": ISODate("2026-02-11T00:00:00Z"), // datetime|null + "created_at": ISODate("2026-02-10T03:00:00Z"), // datetime, 자동 설정 + "updated_at": ISODate("2026-02-10T03:00:00Z") // datetime, 자동 갱신 +} +``` + +**인덱스**: + +```javascript +// 전문 검색 (F-017) +db.todos.createIndex( + { title: "text", content: "text", tags: "text" }, + { weights: { title: 10, tags: 5, content: 1 }, name: "text_search_index" } +) + +// 카테고리 + 생성일 (F-002 카테고리 필터 + 정렬) +db.todos.createIndex({ category_id: 1, created_at: -1 }, { name: "category_created" }) + +// 완료 상태 + 생성일 (F-002 상태 필터 + 정렬) +db.todos.createIndex({ completed: 1, created_at: -1 }, { name: "completed_created" }) + +// 우선순위 + 생성일 (F-002 우선순위 필터 + 정렬) +db.todos.createIndex({ priority: 1, created_at: -1 }, { name: "priority_created" }) + +// 태그 (F-012 태그 필터링) +db.todos.createIndex({ tags: 1 }, { name: "tags" }) + +// 마감일 (F-002 마감일 정렬) +db.todos.createIndex({ due_date: 1 }, { name: "due_date" }) + +// 완료 상태 + 마감일 (F-018 대시보드 마감 임박) +db.todos.createIndex({ completed: 1, due_date: 1 }, { name: "completed_due_date" }) +``` + +#### categories 컬렉션 스키마 + +```javascript +{ + "_id": ObjectId("..."), + "name": "업무", // string, 필수, 1~50자, unique + "color": "#EF4444", // string, hex color, 기본 "#6B7280" + "order": 0, // integer, 정렬 순서 + "created_at": ISODate("2026-02-10T03:00:00Z") +} +``` + +**인덱스**: + +```javascript +// 이름 유니크 (F-007 중복 방지) +db.categories.createIndex({ name: 1 }, { unique: true, name: "category_name_unique" }) + +// 정렬 순서 (F-008 목록 조회) +db.categories.createIndex({ order: 1 }, { name: "category_order" }) +``` + +### 4.2 인덱스 전략 + +#### Text Index (검색용) + +MongoDB text index는 컬렉션당 하나만 생성 가능하다. `title`, `content`, `tags` 세 필드를 하나의 text index로 묶고, 가중치를 설정하여 관련도 검색을 지원한다. + +``` +가중치: title(10) > tags(5) > content(1) +``` + +- `title`에 매칭되는 결과가 가장 높은 점수를 받는다. +- `tags`에 정확히 일치하는 결과가 content 매칭보다 우선한다. +- `{$meta: "textScore"}` 기준으로 정렬하면 관련도 순 정렬이 된다. + +#### Compound Index (필터링/정렬) + +필터링과 정렬이 동시에 발생하는 패턴에 compound index를 설정한다: + +| 쿼리 패턴 | 인덱스 | 설명 | +|-----------|--------|------| +| `completed=true` + `sort=created_at` | `{completed: 1, created_at: -1}` | 가장 빈번한 필터 조합 | +| `category_id=X` + `sort=created_at` | `{category_id: 1, created_at: -1}` | 카테고리별 목록 | +| `priority=high` + `sort=created_at` | `{priority: 1, created_at: -1}` | 우선순위별 목록 | +| `completed=false` + `sort=due_date` | `{completed: 1, due_date: 1}` | 대시보드 마감 임박 | + +### 4.3 쿼리 패턴 + +#### 할일 목록 조회 (F-002) + +```python +# 필터 조건 구성 +query = {} +if completed is not None: + query["completed"] = completed +if category_id: + query["category_id"] = ObjectId(category_id) +if priority: + query["priority"] = priority +if tag: + query["tags"] = tag # multikey index 활용 + +# 정렬 조건 +sort_direction = ASCENDING if order == "asc" else DESCENDING +if sort == "priority": + # priority는 문자열이므로 커스텀 정렬 필요 + # high=0, medium=1, low=2 매핑 -> aggregate 사용 + pass +else: + sort_key = [(sort, sort_direction)] + +# 페이지네이션 +skip = (page - 1) * limit +cursor = collection.find(query).sort(sort_key).skip(skip).limit(limit) +total = await collection.count_documents(query) +``` + +#### 태그 목록 집계 (F-013) + +```python +pipeline = [ + {"$unwind": "$tags"}, + {"$group": {"_id": "$tags", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + {"$project": {"name": "$_id", "count": 1, "_id": 0}}, +] +result = await collection.aggregate(pipeline).to_list(None) +``` + +#### 대시보드 통계 집계 (F-018) + +```python +# Overview +total = await todos.count_documents({}) +completed_count = await todos.count_documents({"completed": True}) +incomplete = total - completed_count +completion_rate = (completed_count / total * 100) if total > 0 else 0 + +# By Category (aggregate) +pipeline_category = [ + {"$group": {"_id": "$category_id", "count": {"$sum": 1}}}, + {"$lookup": { + "from": "categories", + "localField": "_id", + "foreignField": "_id", + "as": "category" + }}, + {"$unwind": {"path": "$category", "preserveNullAndEmptyArrays": True}}, + {"$project": { + "category_id": {"$toString": "$_id"}, + "name": {"$ifNull": ["$category.name", "미분류"]}, + "color": {"$ifNull": ["$category.color", "#6B7280"]}, + "count": 1, + "_id": 0, + }}, +] + +# By Priority +pipeline_priority = [ + {"$group": {"_id": "$priority", "count": {"$sum": 1}}}, +] + +# Upcoming Deadlines (미완료 + 마감일 있음 + 마감일 오름차순 + limit 5) +upcoming = await todos.find({ + "completed": False, + "due_date": {"$ne": None, "$gte": datetime.utcnow()}, +}).sort("due_date", ASCENDING).limit(5).to_list(5) +``` + +#### 카테고리 삭제 시 연쇄 처리 (F-010) + +```python +# 1. 해당 카테고리의 모든 할일 -> category_id = null +await todos.update_many( + {"category_id": ObjectId(category_id)}, + {"$set": {"category_id": None, "updated_at": datetime.utcnow()}} +) +# 2. 카테고리 삭제 +await categories.delete_one({"_id": ObjectId(category_id)}) +``` + +--- + +## 5. API 상세 명세 + +### 5.1 할일 생성 (F-001) + +**Request**: +```http +POST /api/todos +Content-Type: application/json + +{ + "title": "API 문서 작성", + "content": "Swagger UI 기반 API 문서를 작성하고 엔드포인트별 요청/응답 예시를 추가한다.", + "category_id": "65f1a2b3c4d5e6f7a8b9c0d1", + "tags": ["긴급", "문서"], + "priority": "high", + "due_date": "2026-02-11T00:00:00Z" +} +``` + +**Response (201 Created)**: +```json +{ + "id": "65f1a2b3c4d5e6f7a8b9c0d2", + "title": "API 문서 작성", + "content": "Swagger UI 기반 API 문서를 작성하고 엔드포인트별 요청/응답 예시를 추가한다.", + "completed": false, + "priority": "high", + "category_id": "65f1a2b3c4d5e6f7a8b9c0d1", + "category_name": "업무", + "category_color": "#EF4444", + "tags": ["긴급", "문서"], + "due_date": "2026-02-11T00:00:00Z", + "created_at": "2026-02-10T03:00:00Z", + "updated_at": "2026-02-10T03:00:00Z" +} +``` + +**에러 (422)**: +```json +{ + "detail": [ + { + "loc": ["body", "title"], + "msg": "String should have at least 1 character", + "type": "string_too_short" + } + ] +} +``` + +**에러 (404 - 카테고리 미존재)**: +```json +{ + "detail": "카테고리를 찾을 수 없습니다" +} +``` + +### 5.2 할일 목록 조회 (F-002) + +**Request**: +```http +GET /api/todos?page=1&limit=20&completed=false&priority=high&sort=due_date&order=asc +``` + +**Response (200 OK)**: +```json +{ + "items": [ + { + "id": "65f1a2b3c4d5e6f7a8b9c0d2", + "title": "API 문서 작성", + "content": "Swagger UI 기반...", + "completed": false, + "priority": "high", + "category_id": "65f1a2b3c4d5e6f7a8b9c0d1", + "category_name": "업무", + "category_color": "#EF4444", + "tags": ["긴급", "문서"], + "due_date": "2026-02-11T00:00:00Z", + "created_at": "2026-02-10T03:00:00Z", + "updated_at": "2026-02-10T03:00:00Z" + } + ], + "total": 45, + "page": 1, + "limit": 20, + "total_pages": 3 +} +``` + +### 5.3 할일 상세 조회 (F-003) + +**Request**: +```http +GET /api/todos/65f1a2b3c4d5e6f7a8b9c0d2 +``` + +**Response (200 OK)**: Todo 객체 (5.1과 동일 구조) + +**에러 (422 - 잘못된 ID)**: +```json +{ + "detail": "유효하지 않은 ID 형식입니다" +} +``` + +### 5.4 할일 수정 (F-004) + +**Request**: +```http +PUT /api/todos/65f1a2b3c4d5e6f7a8b9c0d2 +Content-Type: application/json + +{ + "title": "API 문서 작성 (v2)", + "priority": "medium", + "tags": ["문서", "api"] +} +``` + +**Response (200 OK)**: 수정된 Todo 객체 + +### 5.5 할일 삭제 (F-005) + +**Request**: +```http +DELETE /api/todos/65f1a2b3c4d5e6f7a8b9c0d2 +``` + +**Response (204 No Content)**: 빈 응답 + +### 5.6 할일 완료 토글 (F-006) + +**Request**: +```http +PATCH /api/todos/65f1a2b3c4d5e6f7a8b9c0d2/toggle +``` + +**Response (200 OK)**: +```json +{ + "id": "65f1a2b3c4d5e6f7a8b9c0d2", + "completed": true +} +``` + +### 5.7 일괄 작업 (F-019, F-020, F-021) + +**일괄 완료 (F-019)**: +```http +POST /api/todos/batch +Content-Type: application/json + +{ + "action": "complete", + "ids": ["65f1...d2", "65f1...d3", "65f1...d4"] +} +``` + +**일괄 삭제 (F-020)**: +```http +POST /api/todos/batch +Content-Type: application/json + +{ + "action": "delete", + "ids": ["65f1...d2", "65f1...d3"] +} +``` + +**일괄 카테고리 변경 (F-021)**: +```http +POST /api/todos/batch +Content-Type: application/json + +{ + "action": "move_category", + "ids": ["65f1...d2", "65f1...d3"], + "category_id": "65f1a2b3c4d5e6f7a8b9c0d1" +} +``` + +**Response (200 OK)**: +```json +{ + "action": "complete", + "processed": 3, + "failed": 0 +} +``` + +### 5.8 카테고리 CRUD + +**목록 조회 (F-008)**: +```http +GET /api/categories +``` + +```json +[ + { + "id": "65f1a2b3c4d5e6f7a8b9c0d1", + "name": "업무", + "color": "#EF4444", + "order": 0, + "todo_count": 12, + "created_at": "2026-02-10T03:00:00Z" + }, + { + "id": "65f1a2b3c4d5e6f7a8b9c0d5", + "name": "개인", + "color": "#3B82F6", + "order": 1, + "todo_count": 8, + "created_at": "2026-02-10T03:00:00Z" + } +] +``` + +**생성 (F-007)**: +```http +POST /api/categories +Content-Type: application/json + +{ + "name": "학습", + "color": "#10B981" +} +``` + +**Response (201 Created)**: +```json +{ + "id": "65f1a2b3c4d5e6f7a8b9c0d6", + "name": "학습", + "color": "#10B981", + "order": 2, + "todo_count": 0, + "created_at": "2026-02-10T03:00:00Z" +} +``` + +**에러 (409 - 이름 중복)**: +```json +{ + "detail": "이미 존재하는 카테고리 이름입니다" +} +``` + +**수정 (F-009)**: +```http +PUT /api/categories/65f1a2b3c4d5e6f7a8b9c0d1 +Content-Type: application/json + +{ + "name": "업무 프로젝트", + "color": "#F59E0B" +} +``` + +**삭제 (F-010)**: +```http +DELETE /api/categories/65f1a2b3c4d5e6f7a8b9c0d1 +``` +**Response (204 No Content)** + +### 5.9 태그 목록 (F-013) + +**Request**: +```http +GET /api/tags +``` + +**Response (200 OK)**: +```json +[ + { "name": "긴급", "count": 8 }, + { "name": "문서", "count": 5 }, + { "name": "회의", "count": 4 }, + { "name": "학습", "count": 3 } +] +``` + +### 5.10 검색 (F-017) + +**Request**: +```http +GET /api/search?q=API%20문서&page=1&limit=20 +``` + +**Response (200 OK)**: +```json +{ + "items": [ + { + "id": "65f1a2b3c4d5e6f7a8b9c0d2", + "title": "API 문서 작성", + "content": "Swagger UI 기반 API 문서를 작성하고...", + "completed": false, + "priority": "high", + "category_id": "65f1a2b3c4d5e6f7a8b9c0d1", + "category_name": "업무", + "category_color": "#EF4444", + "tags": ["긴급", "문서"], + "due_date": "2026-02-11T00:00:00Z", + "created_at": "2026-02-10T03:00:00Z", + "updated_at": "2026-02-10T03:00:00Z" + } + ], + "total": 3, + "query": "API 문서", + "page": 1, + "limit": 20 +} +``` + +**에러 (422 - 검색어 누락)**: +```json +{ + "detail": "검색어를 입력해주세요" +} +``` + +### 5.11 대시보드 통계 (F-018) + +**Request**: +```http +GET /api/dashboard/stats +``` + +**Response (200 OK)**: +```json +{ + "overview": { + "total": 50, + "completed": 30, + "incomplete": 20, + "completion_rate": 60.0 + }, + "by_category": [ + { "category_id": "65f1...d1", "name": "업무", "color": "#EF4444", "count": 15 }, + { "category_id": "65f1...d5", "name": "개인", "color": "#3B82F6", "count": 10 }, + { "category_id": null, "name": "미분류", "color": "#6B7280", "count": 5 } + ], + "by_priority": { + "high": 10, + "medium": 25, + "low": 15 + }, + "upcoming_deadlines": [ + { "id": "65f1...d2", "title": "API 문서 작성", "due_date": "2026-02-11T00:00:00Z", "priority": "high" }, + { "id": "65f1...d7", "title": "디자인 리뷰", "due_date": "2026-02-12T00:00:00Z", "priority": "medium" } + ] +} +``` + +### 5.12 에러 응답 형식 + +모든 에러 응답은 다음 형식을 따른다: + +**단일 에러**: +```json +{ + "detail": "에러 메시지 (한국어)" +} +``` + +**검증 에러 (Pydantic, 422)**: +```json +{ + "detail": [ + { + "loc": ["body", "field_name"], + "msg": "에러 설명", + "type": "error_type" + } + ] +} +``` + +**HTTP 상태 코드 규칙**: + +| 코드 | 의미 | 사용 | +|------|------|------| +| 200 | OK | 조회, 수정, 토글, 일괄 작업 성공 | +| 201 | Created | 생성 성공 | +| 204 | No Content | 삭제 성공 | +| 404 | Not Found | 리소스 미존재 | +| 409 | Conflict | 이름 중복 (카테고리) | +| 422 | Unprocessable Entity | 입력 검증 실패 | +| 500 | Internal Server Error | 서버 내부 오류 | + +--- + +## 6. 캐싱 전략 + +### 6.1 서버 측: Redis 캐싱 + +대시보드 통계만 Redis 캐싱을 적용한다. 할일 목록/상세는 캐싱하지 않는다. + +``` +┌──────────────────┐ ┌──────────────┐ ┌──────────────┐ +│ GET /dashboard │──────>│ Redis │──────>│ MongoDB │ +│ /stats │ │ (캐시) │ │ (집계) │ +└──────────────────┘ └──────────────┘ └──────────────┘ + │ │ │ + │ Cache Hit │ Cache Miss │ + │<───── JSON 반환 ───────│ │ + │ │ │ + │ │── $aggregate ────────>│ + │ │<── 결과 반환 ─────────│ + │ │── SET (TTL 60s) ─────>│(Redis) + │<───── JSON 반환 ───────│ │ +``` + +**캐시 무효화 트리거**: + +| 작업 | 무효화 여부 | 이유 | +|------|------------|------| +| 할일 생성 (POST /todos) | O | total, by_category, by_priority 변경 | +| 할일 수정 (PUT /todos/{id}) | O | priority, category_id, due_date 변경 가능 | +| 할일 삭제 (DELETE /todos/{id}) | O | total, by_category 변경 | +| 할일 토글 (PATCH /todos/{id}/toggle) | O | completed 변경 -> completion_rate | +| 일괄 작업 (POST /todos/batch) | O | 모든 통계 영향 | +| 카테고리 CRUD | O | by_category 변경 | + +### 6.2 클라이언트 측: Tanstack Query 캐싱 + +```typescript +// 쿼리별 캐싱 전략 +const CACHE_CONFIG = { + todos: { + staleTime: 30 * 1000, // 30초 후 stale + gcTime: 5 * 60 * 1000, // 5분 후 GC + }, + categories: { + staleTime: 60 * 1000, // 1분 후 stale + gcTime: 10 * 60 * 1000, // 10분 후 GC + }, + tags: { + staleTime: 60 * 1000, // 1분 후 stale + gcTime: 10 * 60 * 1000, // 10분 후 GC + }, + dashboard: { + staleTime: 60 * 1000, // 1분 (서버 Redis TTL과 동일) + gcTime: 5 * 60 * 1000, // 5분 후 GC + }, + search: { + staleTime: 0, // 항상 fresh fetch + gcTime: 5 * 60 * 1000, // 5분 후 GC + }, +}; +``` + +**Mutation 후 무효화 매트릭스**: + +| Mutation | todos.lists | todos.detail | categories | tags | dashboard | +|----------|:-----------:|:------------:|:----------:|:----:|:---------:| +| createTodo | O | - | O | O | O | +| updateTodo | O | O | O | O | O | +| deleteTodo | O | - | O | O | O | +| toggleTodo | O | O | - | - | O | +| batchAction | O | - | O | O | O | +| createCategory | - | - | O | - | O | +| updateCategory | O | O | O | - | O | +| deleteCategory | O | O | O | - | O | + +--- + +## 7. Docker 구성 + +### 7.1 docker-compose.yml + +```yaml +version: "3.8" + +services: + # --- Backend --- + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: todos2-backend + ports: + - "8000:8000" + environment: + - MONGODB_URL=mongodb://mongodb:27017 + - MONGODB_DATABASE=todos2 + - REDIS_URL=redis://redis:6379 + - FRONTEND_URL=http://localhost:3000 + depends_on: + mongodb: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./backend/app:/app/app # 개발 시 핫 리로드 + networks: + - todos2-network + restart: unless-stopped + + # --- Frontend --- + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: todos2-frontend + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000 + depends_on: + - backend + volumes: + - ./frontend/src:/app/src # 개발 시 핫 리로드 + networks: + - todos2-network + restart: unless-stopped + + # --- MongoDB --- + mongodb: + image: mongo:7.0 + container_name: todos2-mongodb + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - todos2-network + restart: unless-stopped + + # --- Redis --- + redis: + image: redis:7-alpine + container_name: todos2-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + command: redis-server --appendonly yes + networks: + - todos2-network + restart: unless-stopped + +volumes: + mongodb_data: + driver: local + redis_data: + driver: local + +networks: + todos2-network: + driver: bridge +``` + +### 7.2 Backend Dockerfile + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# 시스템 의존성 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Python 의존성 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 소스 코드 +COPY app/ ./app/ + +# 포트 노출 +EXPOSE 8000 + +# 실행 (개발 모드: 핫 리로드) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +``` + +### 7.3 Backend requirements.txt + +``` +fastapi>=0.104 +uvicorn>=0.24 +motor>=3.6,<4.0 +pymongo>=4.9,<4.10 +pydantic>=2.5 +pydantic-settings>=2.1 +redis>=5.0 +python-dateutil>=2.8 +``` + +> **호환성 주의**: `pymongo>=4.9,<4.10` + `motor>=3.6` 조합을 사용한다. +> pymongo 4.9.x에서 `if not db:` 패턴이 `NotImplementedError`를 발생시키므로, +> 반드시 `if db is None:` 패턴을 사용해야 한다. + +### 7.4 Frontend Dockerfile + +```dockerfile +FROM node:20-alpine + +WORKDIR /app + +# 의존성 설치 +COPY package.json package-lock.json* ./ +RUN npm ci + +# 소스 코드 +COPY . . + +# 포트 노출 +EXPOSE 3000 + +# 개발 모드 실행 +CMD ["npm", "run", "dev"] +``` + +### 7.5 환경변수 + +**Backend (.env)**: + +```env +MONGODB_URL=mongodb://mongodb:27017 +MONGODB_DATABASE=todos2 +REDIS_URL=redis://redis:6379 +FRONTEND_URL=http://localhost:3000 +``` + +**Frontend (.env.local)**: + +```env +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +--- + +## 8. 개발 컨벤션 + +### 8.1 파일 네이밍 규칙 + +| 대상 | 규칙 | 예시 | +|------|------|------| +| **Python 모듈** | snake_case | `todo_service.py`, `dashboard_service.py` | +| **Python 클래스** | PascalCase | `TodoService`, `CategoryCreate` | +| **React 컴포넌트** | PascalCase.tsx | `TodoCard.tsx`, `SearchBar.tsx` | +| **React Hooks** | camelCase (use 접두사) | `useTodos.ts`, `useCategories.ts` | +| **TypeScript 타입** | PascalCase | `Todo`, `CategoryResponse` | +| **API 경로** | kebab-case (단수/복수) | `/api/todos`, `/api/categories` | +| **CSS 클래스** | Tailwind 유틸리티 | `className="flex items-center gap-2"` | +| **환경변수** | SCREAMING_SNAKE_CASE | `MONGODB_URL`, `NEXT_PUBLIC_API_URL` | + +### 8.2 코드 스타일 + +**Python (Backend)**: +- Type hints 필수 사용 +- `async`/`await` 기반 비동기 코드 +- Pydantic v2 `model_dump()`, `model_validate()` 사용 (`dict()`, `parse_obj()` 금지) +- MongoDB `_id` <-> API `id` 변환 시 `by_alias=True` 사용 +- docstring: 함수마다 한 줄 설명 + 비즈니스 규칙 번호 (F-XXX) + +**TypeScript (Frontend)**: +- 모든 props에 TypeScript interface 정의 +- `"use client"` 지시어: 상태/이벤트가 있는 컴포넌트에만 사용 +- API 응답 타입은 `types/index.ts`에서 import +- shadcn/ui 컴포넌트 커스터마이징은 원본 수정 없이 wrapping + +### 8.3 에러 처리 패턴 + +**Backend**: + +```python +from fastapi import HTTPException + +# 리소스 미존재 +async def get_todo(self, todo_id: str) -> TodoResponse: + if not ObjectId.is_valid(todo_id): + raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") + + doc = await self.collection.find_one({"_id": ObjectId(todo_id)}) + if doc is None: # 주의: `if not doc:` 아닌 `if doc is None:` + raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") + + return self._to_response(doc) + +# 중복 (카테고리 이름) +async def create_category(self, data: CategoryCreate) -> CategoryResponse: + existing = await self.collection.find_one({"name": data.name}) + if existing is not None: + raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다") + # ... +``` + +**Frontend**: + +```typescript +// hooks/useTodos.ts +import { toast } from "@/components/ui/use-toast"; + +export function useDeleteTodo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => apiClient.delete(`/api/todos/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); + queryClient.invalidateQueries({ queryKey: dashboardKeys.all }); + toast({ title: "할일이 삭제되었습니다" }); + }, + onError: (error: ApiError) => { + toast({ + title: "삭제 실패", + description: error.detail, + variant: "destructive", + }); + }, + }); +} +``` + +### 8.4 마감일 알림 로직 (F-016, 프론트엔드) + +```typescript +// lib/utils.ts +export type DueDateStatus = "overdue" | "urgent" | "soon" | "normal" | null; + +export function getDueDateStatus(dueDate: string | null, completed: boolean): DueDateStatus { + if (!dueDate || completed) return null; + + const now = new Date(); + const due = new Date(dueDate); + const diffMs = due.getTime() - now.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + if (diffDays < 0) return "overdue"; // 초과: 빨간색 + if (diffDays <= 1) return "urgent"; // 1일 이내: 주황색 + if (diffDays <= 3) return "soon"; // 3일 이내: 노란색 + return "normal"; +} + +export function getDueDateLabel(status: DueDateStatus): string { + switch (status) { + case "overdue": return "초과"; + case "urgent": return "임박"; + case "soon": return "곧 마감"; + default: return ""; + } +} + +export function getDueDateColor(status: DueDateStatus): string { + switch (status) { + case "overdue": return "bg-red-100 text-red-700"; + case "urgent": return "bg-orange-100 text-orange-700"; + case "soon": return "bg-yellow-100 text-yellow-700"; + default: return "bg-gray-100 text-gray-500"; + } +} +``` + +### 8.5 우선순위 색상 규칙 (F-014) + +```typescript +export const PRIORITY_CONFIG = { + high: { label: "높음", color: "bg-red-100 text-red-700", dotColor: "bg-red-500" }, + medium: { label: "중간", color: "bg-yellow-100 text-yellow-700", dotColor: "bg-yellow-500" }, + low: { label: "낮음", color: "bg-blue-100 text-blue-700", dotColor: "bg-blue-500" }, +} as const; +``` + +### 8.6 MongoDB _id 처리 패턴 + +```python +# Service 내부 변환 헬퍼 +def _doc_to_response(self, doc: dict) -> TodoResponse: + """MongoDB 문서 -> API 응답 변환""" + return TodoResponse( + id=str(doc["_id"]), + title=doc["title"], + content=doc.get("content"), + completed=doc["completed"], + priority=doc["priority"], + category_id=str(doc["category_id"]) if doc.get("category_id") else None, + tags=doc.get("tags", []), + due_date=doc.get("due_date"), + created_at=doc["created_at"], + updated_at=doc["updated_at"], + ) +``` + +### 8.7 FastAPI 라우터 등록 순서 + +```python +# app/routers/todos.py +router = APIRouter(prefix="/api/todos", tags=["todos"]) + +# 고정 경로를 패턴 경로보다 먼저 등록 +@router.post("/batch", response_model=BatchResponse) +async def batch_action(...): ... + +@router.get("", response_model=TodoListResponse) +async def list_todos(...): ... + +@router.post("", response_model=TodoResponse, status_code=201) +async def create_todo(...): ... + +# 패턴 경로는 아래에 배치 +@router.get("/{todo_id}", response_model=TodoResponse) +async def get_todo(...): ... + +@router.put("/{todo_id}", response_model=TodoResponse) +async def update_todo(...): ... + +@router.delete("/{todo_id}", status_code=204) +async def delete_todo(...): ... + +@router.patch("/{todo_id}/toggle", response_model=ToggleResponse) +async def toggle_todo(...): ... +``` + +--- + +## 부록 A: 기능-컴포넌트 매핑 + +21개 기능이 아키텍처의 어디에서 구현되는지 추적표: + +| 기능 ID | 기능명 | Backend Router | Backend Service | Frontend Page | Frontend Component | +|---------|--------|----------------|-----------------|---------------|-------------------| +| F-001 | 할일 생성 | todos.py | TodoService.create_todo | P-002 | TodoForm | +| F-002 | 할일 목록 조회 | todos.py | TodoService.list_todos | P-002 | TodoList, TodoFilter | +| F-003 | 할일 상세 조회 | todos.py | TodoService.get_todo | P-003 | TodoDetailForm | +| F-004 | 할일 수정 | todos.py | TodoService.update_todo | P-003 | TodoDetailForm | +| F-005 | 할일 삭제 | todos.py | TodoService.delete_todo | P-002 | TodoCard | +| F-006 | 할일 완료 토글 | todos.py | TodoService.toggle_todo | P-002 | TodoCard | +| F-007 | 카테고리 생성 | categories.py | CategoryService.create | P-004 | CategoryForm | +| F-008 | 카테고리 목록 조회 | categories.py | CategoryService.list | P-004 | CategoryList, Sidebar | +| F-009 | 카테고리 수정 | categories.py | CategoryService.update | P-004 | CategoryForm | +| F-010 | 카테고리 삭제 | categories.py | CategoryService.delete | P-004 | CategoryItem | +| F-011 | 태그 부여 | todos.py | TodoService (create/update) | P-002, P-003 | TagSelect, TagInput | +| F-012 | 태그별 필터링 | todos.py | TodoService.list_todos | P-002 | TagBadge, TodoFilter | +| F-013 | 태그 목록 조회 | tags.py | TodoService.get_tags | Sidebar | Sidebar (Popular Tags) | +| F-014 | 우선순위 설정 | todos.py | TodoService (create/update) | P-002, P-003 | PrioritySelect | +| F-015 | 마감일 설정 | todos.py | TodoService (create/update) | P-002, P-003 | DatePicker | +| F-016 | 마감일 알림 표시 | - (프론트엔드 전용) | - | P-002 | DueDateBadge | +| F-017 | 검색 | search.py | SearchService.search | P-005 | SearchBar, SearchResults | +| F-018 | 대시보드 통계 | dashboard.py | DashboardService.get_stats | P-001 | StatsCards, Charts | +| F-019 | 일괄 완료 처리 | todos.py (batch) | TodoService.batch_complete | P-002 | BatchActions | +| F-020 | 일괄 삭제 | todos.py (batch) | TodoService.batch_delete | P-002 | BatchActions | +| F-021 | 일괄 카테고리 변경 | todos.py (batch) | TodoService.batch_move | P-002 | BatchActions | diff --git a/docs/FEATURE_SPEC.md b/docs/FEATURE_SPEC.md new file mode 100644 index 0000000..9e9d8a3 --- /dev/null +++ b/docs/FEATURE_SPEC.md @@ -0,0 +1,789 @@ +# 기능 정의서 (Feature Specification) + +> 프로젝트: todos2 | 버전: 1.0.0 | 작성일: 2026-02-10 + +--- + +## 1. 기능 목록 (Feature Inventory) + +| # | 기능명 | 우선순위 | 카테고리 | 상태 | +|---|--------|---------|---------|------| +| F-001 | 할일 생성 | Must | 할일 CRUD | 미개발 | +| F-002 | 할일 목록 조회 | Must | 할일 CRUD | 미개발 | +| F-003 | 할일 상세 조회 | Must | 할일 CRUD | 미개발 | +| F-004 | 할일 수정 | Must | 할일 CRUD | 미개발 | +| F-005 | 할일 삭제 | Must | 할일 CRUD | 미개발 | +| F-006 | 할일 완료 토글 | Must | 할일 CRUD | 미개발 | +| F-007 | 카테고리 생성 | Must | 카테고리 | 미개발 | +| F-008 | 카테고리 목록 조회 | Must | 카테고리 | 미개발 | +| F-009 | 카테고리 수정 | Must | 카테고리 | 미개발 | +| F-010 | 카테고리 삭제 | Must | 카테고리 | 미개발 | +| F-011 | 태그 부여 | Must | 태그 | 미개발 | +| F-012 | 태그별 필터링 | Must | 태그 | 미개발 | +| F-013 | 태그 목록 조회 | Must | 태그 | 미개발 | +| F-014 | 우선순위 설정 | Must | 우선순위 | 미개발 | +| F-015 | 마감일 설정 | Must | 마감일 | 미개발 | +| F-016 | 마감일 알림 표시 | Must | 마감일 | 미개발 | +| F-017 | 검색 | Should | 검색 | 미개발 | +| F-018 | 대시보드 통계 | Should | 대시보드 | 미개발 | +| F-019 | 일괄 완료 처리 | Should | 일괄 작업 | 미개발 | +| F-020 | 일괄 삭제 | Should | 일괄 작업 | 미개발 | +| F-021 | 일괄 카테고리 변경 | Should | 일괄 작업 | 미개발 | + +--- + +## 2. 기능 상세 정의 + +--- + +### F-001: 할일 생성 + +- **설명**: 사용자가 제목, 내용, 카테고리, 태그, 우선순위, 마감일을 입력하여 새로운 할일을 생성한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| title | string | Y | 1~200자, 공백만 불가 | 할일 제목 | +| content | string | N | 최대 2000자 | 할일 상세 내용 | +| category_id | string | N | 유효한 카테고리 ObjectId | 분류 카테고리 | +| tags | string[] | N | 각 태그 1~30자, 최대 10개 | 태그 목록 | +| priority | enum | N | high / medium / low (기본: medium) | 우선순위 | +| due_date | datetime | N | 현재 시각 이후 (생성 시점 기준) | 마감일 | + +#### 처리 규칙 (Business Rules) + +1. title 앞뒤 공백을 제거(trim)한다. +2. tags 배열 내 중복 태그를 제거하고, 각 태그를 소문자로 정규화한다. +3. category_id가 지정된 경우, 해당 카테고리가 존재하는지 검증한다. +4. created_at, updated_at을 현재 시각(UTC)으로 설정한다. +5. completed는 false로 초기화한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 201 Created | 생성된 Todo 객체 (id 포함) | +| 제목 누락/검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 | +| 카테고리 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 제목만 입력하여 할일을 생성할 수 있다 +- [ ] 모든 필드(제목, 내용, 카테고리, 태그, 우선순위, 마감일)를 입력하여 할일을 생성할 수 있다 +- [ ] 제목 없이 생성 시 422 에러가 반환된다 +- [ ] 201자 이상의 제목 입력 시 422 에러가 반환된다 +- [ ] 존재하지 않는 카테고리 ID 입력 시 404 에러가 반환된다 +- [ ] 생성 후 할일 목록에 즉시 반영된다 +- [ ] 태그 중복이 자동으로 제거된다 + +--- + +### F-002: 할일 목록 조회 + +- **설명**: 필터링, 정렬, 페이지네이션을 적용하여 할일 목록을 조회한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| page | integer | N | >= 1, 기본: 1 | 페이지 번호 | +| limit | integer | N | 1~100, 기본: 20 | 페이지당 항목 수 | +| completed | boolean | N | true / false | 완료 상태 필터 | +| category_id | string | N | 유효한 ObjectId | 카테고리 필터 | +| priority | enum | N | high / medium / low | 우선순위 필터 | +| tag | string | N | - | 태그 필터 | +| sort | string | N | created_at / due_date / priority | 정렬 기준 (기본: created_at) | +| order | enum | N | asc / desc (기본: desc) | 정렬 방향 | + +#### 처리 규칙 (Business Rules) + +1. 필터 조건이 복수인 경우 AND 조건으로 적용한다. +2. 카테고리 필터 시 해당 category의 이름과 색상을 populate한다. +3. 페이지네이션 응답에 총 개수(total)와 총 페이지 수(total_pages)를 포함한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `{"items": [...], "total": N, "page": N, "limit": N, "total_pages": N}` | +| 잘못된 파라미터 | 422 Unprocessable Entity | 필드별 에러 메시지 | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 필터 없이 전체 할일 목록을 조회할 수 있다 +- [ ] 완료/미완료 상태로 필터링할 수 있다 +- [ ] 카테고리별로 필터링할 수 있다 +- [ ] 우선순위별로 필터링할 수 있다 +- [ ] 태그별로 필터링할 수 있다 +- [ ] 페이지네이션이 정상 동작한다 (총 개수, 총 페이지 수 포함) +- [ ] 정렬 기준(생성일, 마감일, 우선순위)과 방향(오름차순/내림차순)을 지정할 수 있다 + +--- + +### F-003: 할일 상세 조회 + +- **설명**: 특정 할일의 전체 정보를 조회한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| id | string | Y | 유효한 ObjectId | 할일 ID | + +#### 처리 규칙 (Business Rules) + +1. 카테고리가 지정된 경우, 카테고리 이름과 색상을 함께 반환한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | Todo 객체 (카테고리 정보 포함) | +| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` | +| 잘못된 ID 형식 | 422 Unprocessable Entity | `{"detail": "유효하지 않은 ID 형식입니다"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 유효한 ID로 할일 상세 정보를 조회할 수 있다 +- [ ] 카테고리 정보가 함께 반환된다 +- [ ] 존재하지 않는 ID로 조회 시 404가 반환된다 + +--- + +### F-004: 할일 수정 + +- **설명**: 기존 할일의 제목, 내용, 카테고리, 태그, 우선순위, 마감일을 수정한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| id | string (path) | Y | 유효한 ObjectId | 할일 ID | +| title | string | N | 1~200자 | 할일 제목 | +| content | string | N | 최대 2000자 | 할일 상세 내용 | +| category_id | string \| null | N | 유효한 ObjectId 또는 null | 카테고리 (null이면 해제) | +| tags | string[] | N | 각 태그 1~30자, 최대 10개 | 태그 목록 | +| priority | enum | N | high / medium / low | 우선순위 | +| due_date | datetime \| null | N | null이면 해제 | 마감일 | + +#### 처리 규칙 (Business Rules) + +1. 요청에 포함된 필드만 업데이트한다 (Partial Update). +2. updated_at을 현재 시각(UTC)으로 갱신한다. +3. category_id가 지정된 경우 해당 카테고리의 존재를 검증한다. +4. tags 배열이 지정된 경우 중복 제거 및 소문자 정규화를 적용한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | 수정된 Todo 객체 | +| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` | +| 검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 제목만 수정할 수 있다 +- [ ] 카테고리를 변경할 수 있다 +- [ ] 카테고리를 null로 설정하여 해제할 수 있다 +- [ ] 태그를 추가/삭제할 수 있다 +- [ ] 우선순위를 변경할 수 있다 +- [ ] 마감일을 설정/해제할 수 있다 +- [ ] 수정 후 updated_at이 갱신된다 +- [ ] 존재하지 않는 할일 수정 시 404가 반환된다 + +--- + +### F-005: 할일 삭제 + +- **설명**: 특정 할일을 영구 삭제한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| id | string | Y | 유효한 ObjectId | 할일 ID | + +#### 처리 규칙 (Business Rules) + +1. 물리 삭제를 수행한다. +2. 삭제 전 확인 다이얼로그를 프론트엔드에서 표시한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 204 No Content | 빈 응답 | +| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 할일을 삭제할 수 있다 +- [ ] 삭제 전 확인 다이얼로그가 표시된다 +- [ ] 삭제 후 목록에서 즉시 사라진다 +- [ ] 존재하지 않는 할일 삭제 시 404가 반환된다 + +--- + +### F-006: 할일 완료 토글 + +- **설명**: 할일의 완료/미완료 상태를 전환한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| id | string (path) | Y | 유효한 ObjectId | 할일 ID | + +#### 처리 규칙 (Business Rules) + +1. 현재 completed 값의 반대값으로 토글한다. +2. updated_at을 갱신한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `{"id": "...", "completed": true/false}` | +| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 체크박스 클릭으로 완료/미완료를 토글할 수 있다 +- [ ] 완료된 할일은 시각적으로 구분된다 (취소선, 흐림 처리 등) +- [ ] 토글 후 대시보드 통계가 갱신된다 + +--- + +### F-007: 카테고리 생성 + +- **설명**: 할일 분류를 위한 새 카테고리를 생성한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| name | string | Y | 1~50자, 고유 | 카테고리 이름 | +| color | string | N | 유효한 hex color (기본: #6B7280) | 표시 색상 | + +#### 처리 규칙 (Business Rules) + +1. name 앞뒤 공백을 제거(trim)한다. +2. 동일한 이름의 카테고리가 이미 존재하면 409 Conflict를 반환한다. +3. order는 현재 최대값 + 1로 자동 설정한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 201 Created | 생성된 Category 객체 | +| 이름 중복 | 409 Conflict | `{"detail": "이미 존재하는 카테고리 이름입니다"}` | +| 검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 이름과 색상을 지정하여 카테고리를 생성할 수 있다 +- [ ] 이름만으로 생성 시 기본 색상이 적용된다 +- [ ] 중복 이름 생성 시 에러가 표시된다 +- [ ] 생성 후 사이드바 카테고리 목록에 즉시 반영된다 + +--- + +### F-008: 카테고리 목록 조회 + +- **설명**: 전체 카테고리 목록을 조회한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +없음 (파라미터 없이 전체 조회) + +#### 처리 규칙 (Business Rules) + +1. order 필드 기준 오름차순으로 정렬한다. +2. 각 카테고리에 속한 할일 수(todo_count)를 함께 반환한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `[{"_id": "...", "name": "...", "color": "...", "order": N, "todo_count": N}]` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 전체 카테고리 목록을 조회할 수 있다 +- [ ] 각 카테고리에 할일 수가 표시된다 +- [ ] 정렬 순서대로 표시된다 + +--- + +### F-009: 카테고리 수정 + +- **설명**: 기존 카테고리의 이름, 색상, 순서를 수정한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| id | string (path) | Y | 유효한 ObjectId | 카테고리 ID | +| name | string | N | 1~50자, 고유 | 카테고리 이름 | +| color | string | N | 유효한 hex color | 표시 색상 | +| order | integer | N | >= 0 | 정렬 순서 | + +#### 처리 규칙 (Business Rules) + +1. 요청에 포함된 필드만 업데이트한다. +2. name 변경 시 중복 검사를 수행한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | 수정된 Category 객체 | +| 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` | +| 이름 중복 | 409 Conflict | `{"detail": "이미 존재하는 카테고리 이름입니다"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 카테고리 이름을 변경할 수 있다 +- [ ] 카테고리 색상을 변경할 수 있다 +- [ ] 변경 사항이 해당 카테고리의 모든 할일 표시에 반영된다 +- [ ] 중복 이름으로 변경 시 에러가 표시된다 + +--- + +### F-010: 카테고리 삭제 + +- **설명**: 카테고리를 삭제한다. 해당 카테고리에 속한 할일의 category_id는 null로 초기화된다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| id | string (path) | Y | 유효한 ObjectId | 카테고리 ID | + +#### 처리 규칙 (Business Rules) + +1. 카테고리 삭제 시, 해당 카테고리에 속한 모든 할일의 category_id를 null로 변경한다. +2. 삭제 전 확인 다이얼로그에 영향받는 할일 수를 표시한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 204 No Content | 빈 응답 | +| 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 카테고리를 삭제할 수 있다 +- [ ] 삭제 확인 다이얼로그에 영향받는 할일 수가 표시된다 +- [ ] 삭제 후 해당 카테고리의 할일들이 "미분류"로 표시된다 +- [ ] 사이드바에서 즉시 사라진다 + +--- + +### F-011: 태그 부여 + +- **설명**: 할일 생성 또는 수정 시 태그를 부여한다. 콤마로 구분하여 다중 태그를 입력한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| tags | string[] | N | 각 1~30자, 최대 10개, 특수문자 불가 | 태그 배열 | + +#### 처리 규칙 (Business Rules) + +1. F-001(할일 생성), F-004(할일 수정)에서 tags 필드로 처리된다. +2. 입력 태그를 소문자로 정규화하고, 중복을 제거한다. +3. 기존 태그 자동완성을 위해 사용 중인 태그 목록을 제공한다. + +#### 출력 (Outputs) + +F-001, F-004의 출력과 동일 (Todo 객체에 tags 포함) + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 할일 생성/수정 폼에서 태그를 입력할 수 있다 +- [ ] 기존 태그 자동완성이 동작한다 +- [ ] 태그가 뱃지 형태로 표시된다 +- [ ] 태그를 개별 삭제할 수 있다 (x 버튼) + +--- + +### F-012: 태그별 필터링 + +- **설명**: 특정 태그가 부여된 할일만 필터링하여 조회한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| tag | string | Y | 존재하는 태그 | 필터링할 태그명 | + +#### 처리 규칙 (Business Rules) + +1. F-002(할일 목록 조회)의 tag 파라미터로 처리된다. +2. 태그 뱃지 클릭 시 해당 태그로 필터링된다. + +#### 출력 (Outputs) + +F-002의 출력과 동일 + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 태그 뱃지 클릭으로 해당 태그의 할일만 필터링된다 +- [ ] 필터 적용 중 태그명이 표시된다 +- [ ] 필터 해제 버튼으로 전체 목록으로 돌아갈 수 있다 + +--- + +### F-013: 태그 목록 조회 + +- **설명**: 현재 사용 중인 모든 태그의 목록과 사용 횟수를 조회한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +없음 + +#### 처리 규칙 (Business Rules) + +1. todos 컬렉션에서 tags 필드를 distinct로 추출한다. +2. 각 태그의 사용 횟수(count)를 집계한다. +3. 사용 횟수 내림차순으로 정렬한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `[{"name": "업무", "count": 5}, ...]` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 사용 중인 모든 태그 목록을 조회할 수 있다 +- [ ] 각 태그의 사용 횟수가 표시된다 +- [ ] 할일이 없는 태그는 목록에 표시되지 않는다 + +--- + +### F-014: 우선순위 설정 + +- **설명**: 할일에 높음(high), 중간(medium), 낮음(low) 3단계 우선순위를 설정한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| priority | enum | N | high / medium / low | 우선순위 (기본: medium) | + +#### 처리 규칙 (Business Rules) + +1. F-001(할일 생성), F-004(할일 수정)의 priority 필드로 처리된다. +2. UI에서 색상으로 구분: high=빨강, medium=노랑, low=파랑. + +#### 출력 (Outputs) + +F-001, F-004의 출력과 동일 + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 할일 생성/수정 시 우선순위를 선택할 수 있다 +- [ ] 우선순위별 색상 구분이 된다 (high=빨강, medium=노랑, low=파랑) +- [ ] 기본 우선순위는 medium이다 +- [ ] 목록에서 우선순위별 필터링이 가능하다 + +--- + +### F-015: 마감일 설정 + +- **설명**: 할일에 마감일을 설정한다. 달력 UI로 날짜를 선택한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| due_date | datetime | N | ISO 8601 형식 | 마감일 | + +#### 처리 규칙 (Business Rules) + +1. F-001(할일 생성), F-004(할일 수정)의 due_date 필드로 처리된다. +2. null을 전송하면 마감일을 해제한다. + +#### 출력 (Outputs) + +F-001, F-004의 출력과 동일 + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 달력 UI로 마감일을 선택할 수 있다 +- [ ] 마감일이 설정된 할일에 날짜가 표시된다 +- [ ] 마감일을 해제할 수 있다 + +--- + +### F-016: 마감일 알림 표시 + +- **설명**: 마감일이 임박하거나 초과한 할일에 시각적 알림을 표시한다. +- **우선순위**: Must + +#### 입력 (Inputs) + +없음 (프론트엔드 렌더링 로직) + +#### 처리 규칙 (Business Rules) + +1. 마감일 1일 이내: 주황색 "임박" 뱃지 표시 +2. 마감일 초과: 빨간색 "초과" 뱃지 표시 +3. 마감일 3일 이내: 노란색 "곧 마감" 표시 +4. 완료된 할일은 알림을 표시하지 않는다. + +#### 출력 (Outputs) + +UI 렌더링 (API 응답 없음) + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 마감 3일 이내 할일에 "곧 마감" 표시가 된다 +- [ ] 마감 1일 이내 할일에 "임박" 표시가 된다 +- [ ] 마감 초과 할일에 "초과" 표시가 된다 +- [ ] 완료된 할일에는 알림이 표시되지 않는다 +- [ ] 뱃지 색상이 긴급도에 따라 구분된다 (노랑/주황/빨강) + +--- + +### F-017: 검색 + +- **설명**: 제목, 내용, 태그를 기반으로 할일을 검색한다. +- **우선순위**: Should + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| q | string | Y | 1~200자 | 검색 키워드 | +| page | integer | N | >= 1, 기본: 1 | 페이지 번호 | +| limit | integer | N | 1~100, 기본: 20 | 페이지당 항목 수 | + +#### 처리 규칙 (Business Rules) + +1. MongoDB text index를 활용한 전문 검색을 수행한다. +2. 검색 대상: title, content, tags 필드. +3. 검색 결과는 관련도(text score) 기준으로 정렬한다. +4. 검색어가 태그와 정확히 일치하는 경우 해당 태그의 할일을 우선 표시한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `{"items": [...], "total": N, "query": "...", "page": N, "limit": N}` | +| 검색어 누락 | 422 Unprocessable Entity | `{"detail": "검색어를 입력해주세요"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 헤더 검색바에 키워드를 입력하여 검색할 수 있다 +- [ ] 제목에 포함된 키워드로 검색된다 +- [ ] 내용에 포함된 키워드로 검색된다 +- [ ] 태그명으로 검색된다 +- [ ] 검색 결과가 관련도순으로 정렬된다 +- [ ] 검색 결과가 없을 때 "결과 없음" 메시지가 표시된다 +- [ ] 검색 결과에서 검색어가 하이라이트된다 + +--- + +### F-018: 대시보드 통계 + +- **설명**: 할일 현황을 한눈에 파악할 수 있는 대시보드 통계를 제공한다. +- **우선순위**: Should + +#### 입력 (Inputs) + +없음 + +#### 처리 규칙 (Business Rules) + +1. 통계 항목: + - 전체 할일 수, 완료 수, 미완료 수 + - 완료율 (%) + - 카테고리별 할일 분포 (도넛 차트) + - 우선순위별 현황 (가로 막대 차트) + - 마감 임박 할일 목록 (상위 5개) +2. Redis에 60초 TTL로 캐싱한다. +3. 캐시 무효화: 할일 CUD 작업 시. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | 아래 JSON 구조 참고 | + +```json +{ + "overview": { + "total": 50, + "completed": 30, + "incomplete": 20, + "completion_rate": 60.0 + }, + "by_category": [ + {"category_id": "...", "name": "업무", "color": "#EF4444", "count": 15} + ], + "by_priority": { + "high": 10, + "medium": 25, + "low": 15 + }, + "upcoming_deadlines": [ + {"id": "...", "title": "...", "due_date": "...", "priority": "high"} + ] +} +``` + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 전체/완료/미완료 수가 카드 형태로 표시된다 +- [ ] 완료율이 퍼센트로 표시된다 +- [ ] 카테고리별 할일 분포가 도넛 차트로 표시된다 +- [ ] 우선순위별 현황이 막대 차트로 표시된다 +- [ ] 마감 임박 할일 상위 5개가 리스트로 표시된다 +- [ ] 데이터가 없을 때 빈 상태 안내가 표시된다 + +--- + +### F-019: 일괄 완료 처리 + +- **설명**: 여러 할일을 선택하여 한번에 완료 처리한다. +- **우선순위**: Should + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| action | string | Y | "complete" | 작업 유형 | +| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 | + +#### 처리 규칙 (Business Rules) + +1. 선택된 모든 할일의 completed를 true로 설정한다. +2. 각 항목의 updated_at을 갱신한다. +3. 존재하지 않는 ID는 무시하고 나머지를 처리한다. +4. 처리된 수와 실패 수를 반환한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `{"action": "complete", "processed": 5, "failed": 0}` | +| ID 누락 | 422 Unprocessable Entity | `{"detail": "대상 할일을 선택해주세요"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 체크박스로 여러 할일을 선택할 수 있다 +- [ ] "일괄 완료" 버튼으로 선택된 할일을 한번에 완료할 수 있다 +- [ ] 처리 결과(성공 수)가 토스트로 표시된다 +- [ ] 처리 후 목록이 갱신된다 + +--- + +### F-020: 일괄 삭제 + +- **설명**: 여러 할일을 선택하여 한번에 삭제한다. +- **우선순위**: Should + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| action | string | Y | "delete" | 작업 유형 | +| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 | + +#### 처리 규칙 (Business Rules) + +1. 선택된 모든 할일을 물리 삭제한다. +2. 삭제 전 확인 다이얼로그를 표시한다. +3. 존재하지 않는 ID는 무시한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `{"action": "delete", "processed": 3, "failed": 0}` | +| ID 누락 | 422 Unprocessable Entity | `{"detail": "대상 할일을 선택해주세요"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] "일괄 삭제" 버튼으로 선택된 할일을 한번에 삭제할 수 있다 +- [ ] 삭제 전 "N개의 할일을 삭제하시겠습니까?" 확인 다이얼로그가 표시된다 +- [ ] 처리 결과가 토스트로 표시된다 +- [ ] 삭제 후 목록이 갱신된다 + +--- + +### F-021: 일괄 카테고리 변경 + +- **설명**: 여러 할일을 선택하여 한번에 카테고리를 변경한다. +- **우선순위**: Should + +#### 입력 (Inputs) + +| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 | +|--------|------|------|-----------|------| +| action | string | Y | "move_category" | 작업 유형 | +| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 | +| category_id | string \| null | Y | 유효한 카테고리 ObjectId 또는 null | 변경할 카테고리 | + +#### 처리 규칙 (Business Rules) + +1. 선택된 모든 할일의 category_id를 지정된 값으로 변경한다. +2. category_id가 null이면 "미분류"로 설정한다. +3. 대상 카테고리의 존재를 검증한다. + +#### 출력 (Outputs) + +| 상황 | HTTP 상태 | 응답 | +|------|-----------|------| +| 성공 | 200 OK | `{"action": "move_category", "processed": 4, "failed": 0}` | +| 카테고리 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` | + +#### 수락 기준 (Acceptance Criteria) + +- [ ] 카테고리 선택 드롭다운으로 일괄 변경할 수 있다 +- [ ] "미분류"로 일괄 변경할 수 있다 +- [ ] 처리 결과가 토스트로 표시된다 +- [ ] 변경 후 목록이 갱신된다 + +--- + +## 3. API 엔드포인트 요약 + +| Method | Path | 기능 ID | 설명 | +|--------|------|---------|------| +| POST | `/api/todos` | F-001 | 할일 생성 | +| GET | `/api/todos` | F-002 | 할일 목록 조회 (필터/정렬/페이지네이션) | +| GET | `/api/todos/{id}` | F-003 | 할일 상세 조회 | +| PUT | `/api/todos/{id}` | F-004 | 할일 수정 | +| DELETE | `/api/todos/{id}` | F-005 | 할일 삭제 | +| PATCH | `/api/todos/{id}/toggle` | F-006 | 할일 완료 토글 | +| POST | `/api/categories` | F-007 | 카테고리 생성 | +| GET | `/api/categories` | F-008 | 카테고리 목록 조회 | +| PUT | `/api/categories/{id}` | F-009 | 카테고리 수정 | +| DELETE | `/api/categories/{id}` | F-010 | 카테고리 삭제 | +| GET | `/api/tags` | F-013 | 태그 목록 조회 | +| GET | `/api/search` | F-017 | 검색 | +| GET | `/api/dashboard/stats` | F-018 | 대시보드 통계 | +| POST | `/api/todos/batch` | F-019, F-020, F-021 | 일괄 작업 (완료/삭제/카테고리 변경) | diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..ce5c6dd --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,259 @@ +# todos2 - 전략 기획안 (Strategic Plan) + +> 버전: 1.0.0 | 작성일: 2026-02-10 | 상태: 기획 완료 + +## 1. 프로젝트 개요 + +**todos2**는 단순 할일 관리를 넘어, 카테고리/태그/우선순위/마감일/검색/대시보드 기능을 갖춘 확장형 할일 관리 애플리케이션이다. + +### 1.1 목표 +- 할일의 체계적 분류 및 관리 (카테고리, 태그, 우선순위) +- 마감일 기반 일정 관리 및 시각적 알림 +- 검색 및 필터링을 통한 빠른 할일 탐색 +- 대시보드를 통한 진행 현황 한눈에 파악 +- 일괄 작업을 통한 효율적인 다중 할일 처리 + +### 1.2 대상 사용자 +- 개인 할일 관리가 필요한 사용자 +- 프로젝트별/카테고리별 작업 분류가 필요한 사용자 + +--- + +## 2. 핵심 기능 + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|---------| +| 1 | 할일 CRUD | 할일 생성, 조회, 수정, 삭제 | Must | +| 2 | 카테고리 관리 | 카테고리 CRUD, 할일별 카테고리 분류 | Must | +| 3 | 태그 시스템 | 할일에 다중 태그 부여, 태그별 필터링 | Must | +| 4 | 우선순위 | 높음/중간/낮음 3단계 우선순위 설정 | Must | +| 5 | 마감일 관리 | 마감일 설정, 임박/초과 알림 표시 | Must | +| 6 | 검색 | 제목/내용/태그 기반 전문 검색 | Should | +| 7 | 대시보드 | 완료율, 카테고리별/우선순위별 통계 차트 | Should | +| 8 | 일괄 작업 | 다중 선택 후 완료/삭제/카테고리 변경 | Should | + +--- + +## 3. 기술 스택 + +### 3.1 백엔드 +| 기술 | 버전 | 용도 | +|------|------|------| +| Python | 3.11 | 런타임 | +| FastAPI | >= 0.104 | REST API 프레임워크 | +| Motor | >= 3.3 | MongoDB 비동기 드라이버 | +| Pydantic v2 | >= 2.5 | 데이터 검증 및 직렬화 | +| Uvicorn | >= 0.24 | ASGI 서버 | +| Redis (aioredis) | >= 2.0 | 캐싱 (대시보드 통계) | + +### 3.2 프론트엔드 +| 기술 | 버전 | 용도 | +|------|------|------| +| Next.js | 15 (App Router) | React 프레임워크 | +| TypeScript | 5.x | 타입 안정성 | +| Tailwind CSS | 4.x | 유틸리티 기반 스타일링 | +| shadcn/ui | latest | UI 컴포넌트 라이브러리 | +| Recharts | 2.x | 대시보드 차트 | +| Tanstack Query | 5.x | 서버 상태 관리 및 캐싱 | +| Zustand | 5.x | 클라이언트 상태 관리 | + +### 3.3 인프라 +| 기술 | 용도 | +|------|------| +| MongoDB 7.0 | 메인 데이터베이스 | +| Redis 7 | 캐싱 레이어 | +| Docker Compose | 로컬 개발 환경 | + +--- + +## 4. 프로젝트 구조 + +``` +todos2/ +├── docker-compose.yml +├── CLAUDE.md +├── PLAN.md +├── FEATURE_SPEC.md +├── SCREEN_DESIGN.pptx +├── SCREEN_DESIGN.md +│ +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ └── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI 앱 진입점 +│ ├── database.py # MongoDB 연결 설정 +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── todo.py # Todo 모델 +│ │ ├── category.py # Category 모델 +│ │ └── tag.py # Tag 모델 +│ ├── routers/ +│ │ ├── __init__.py +│ │ ├── todos.py # 할일 CRUD API +│ │ ├── categories.py # 카테고리 API +│ │ ├── tags.py # 태그 API +│ │ ├── search.py # 검색 API +│ │ ├── dashboard.py # 대시보드 통계 API +│ │ └── batch.py # 일괄 작업 API +│ └── services/ +│ ├── __init__.py +│ ├── todo_service.py +│ ├── category_service.py +│ ├── tag_service.py +│ ├── search_service.py +│ └── dashboard_service.py +│ +└── frontend/ + ├── Dockerfile + ├── package.json + ├── next.config.ts + ├── tailwind.config.ts + ├── tsconfig.json + └── src/ + ├── app/ + │ ├── layout.tsx # 루트 레이아웃 + │ ├── page.tsx # 대시보드 (메인) + │ ├── todos/ + │ │ └── page.tsx # 할일 목록 + │ ├── categories/ + │ │ └── page.tsx # 카테고리 관리 + │ └── search/ + │ └── page.tsx # 검색 결과 + ├── components/ + │ ├── layout/ + │ │ ├── Header.tsx + │ │ ├── Sidebar.tsx + │ │ └── MainLayout.tsx + │ ├── todos/ + │ │ ├── TodoCard.tsx + │ │ ├── TodoForm.tsx + │ │ ├── TodoList.tsx + │ │ ├── TodoFilter.tsx + │ │ └── BatchActions.tsx + │ ├── categories/ + │ │ ├── CategoryList.tsx + │ │ └── CategoryForm.tsx + │ ├── tags/ + │ │ ├── TagBadge.tsx + │ │ ├── TagSelect.tsx + │ │ └── TagManager.tsx + │ ├── dashboard/ + │ │ ├── StatsCards.tsx + │ │ ├── CompletionChart.tsx + │ │ ├── CategoryChart.tsx + │ │ └── PriorityChart.tsx + │ └── search/ + │ ├── SearchBar.tsx + │ └── SearchResults.tsx + ├── hooks/ + │ ├── useTodos.ts + │ ├── useCategories.ts + │ ├── useTags.ts + │ ├── useDashboard.ts + │ └── useSearch.ts + ├── lib/ + │ ├── api.ts # API 클라이언트 + │ └── utils.ts # 유틸리티 함수 + ├── store/ + │ └── uiStore.ts # UI 상태 (사이드바, 필터 등) + └── types/ + └── index.ts # TypeScript 타입 정의 +``` + +--- + +## 5. 데이터 모델 + +### 5.1 Todo +```json +{ + "_id": "ObjectId", + "title": "string (필수, 1~200자)", + "content": "string (선택, 최대 2000자)", + "completed": "boolean (기본: false)", + "priority": "enum: high | medium | low (기본: medium)", + "category_id": "ObjectId | null", + "tags": ["string"], + "due_date": "datetime | null", + "created_at": "datetime", + "updated_at": "datetime" +} +``` + +### 5.2 Category +```json +{ + "_id": "ObjectId", + "name": "string (필수, 1~50자, 고유)", + "color": "string (hex color, 기본: #6B7280)", + "order": "integer (정렬 순서)", + "created_at": "datetime" +} +``` + +### 5.3 Tag (인라인) +태그는 별도 컬렉션 없이 Todo 문서 내 배열로 관리한다. +태그 목록은 todos 컬렉션에서 distinct 쿼리로 추출한다. + +--- + +## 6. 개발 우선순위 (Phase별) + +### Phase 1: 핵심 기반 (MVP) +> 목표: 기본 할일 관리가 가능한 최소 기능 제품 + +1. 프로젝트 초기 세팅 (백엔드/프론트엔드 스캐폴딩) +2. MongoDB 연결 및 데이터 모델 정의 +3. 할일 CRUD API + UI +4. 카테고리 CRUD API + UI +5. 기본 레이아웃 (Header, Sidebar, MainLayout) + +### Phase 2: 확장 기능 +> 목표: 분류/필터/우선순위로 할일 관리 고도화 + +6. 태그 시스템 (태그 입력, 표시, 필터링) +7. 우선순위 설정 (높음/중간/낮음) +8. 마감일 설정 및 임박/초과 알림 UI +9. 검색 기능 (제목/내용/태그) + +### Phase 3: 분석 및 효율 +> 목표: 통계 및 일괄 작업으로 생산성 향상 + +10. 대시보드 통계 API +11. 대시보드 차트 UI (완료율, 카테고리별, 우선순위별) +12. 일괄 작업 (다중 선택, 일괄 완료/삭제/카테고리 변경) + +--- + +## 7. API 설계 요약 + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/todos` | 할일 목록 (필터/페이지네이션) | +| POST | `/api/todos` | 할일 생성 | +| GET | `/api/todos/{id}` | 할일 상세 | +| PUT | `/api/todos/{id}` | 할일 수정 | +| DELETE | `/api/todos/{id}` | 할일 삭제 | +| POST | `/api/todos/batch` | 일괄 작업 | +| GET | `/api/categories` | 카테고리 목록 | +| POST | `/api/categories` | 카테고리 생성 | +| PUT | `/api/categories/{id}` | 카테고리 수정 | +| DELETE | `/api/categories/{id}` | 카테고리 삭제 | +| GET | `/api/tags` | 사용 중인 태그 목록 | +| GET | `/api/search` | 검색 | +| GET | `/api/dashboard/stats` | 대시보드 통계 | + +--- + +## 8. 비기능 요구사항 + +| 항목 | 기준 | +|------|------| +| API 응답 시간 | 95% 요청 200ms 이내 | +| 페이지네이션 | 기본 20건, 최대 100건 | +| 검색 | MongoDB text index 활용 | +| 캐싱 | 대시보드 통계 Redis 캐시 (TTL 60초) | +| CORS | 프론트엔드 도메인 허용 | +| 에러 처리 | 표준 HTTP 상태 코드 + JSON 에러 응답 | diff --git a/docs/SCREEN_DESIGN.md b/docs/SCREEN_DESIGN.md new file mode 100644 index 0000000..57acb88 --- /dev/null +++ b/docs/SCREEN_DESIGN.md @@ -0,0 +1,312 @@ +# todos2 — 화면설계서 + +> 자동 생성: `pptx_to_md.py` | 원본: `SCREEN_DESIGN.pptx` +> 생성 시각: 2026-02-10 07:12 +> **이 파일을 직접 수정하지 마세요. PPTX를 수정 후 스크립트를 재실행하세요.** + +## 페이지 목록 + +| ID | 페이지명 | 경로 | 설명 | +|-----|---------|------|------| +| P-001 | 대시보드 | `/` | 메인 페이지. 통계 카드, 차트, 마감 임박 목록 | +| P-002 | 할일 목록 | `/todos` | 할일 CRUD, 필터링, 정렬, 일괄 작업 | +| P-003 | 할일 상세/편집 | `/todos/[id]` | 할일 상세 보기 및 수정 폼 | +| P-004 | 카테고리 관리 | `/categories` | 카테고리 CRUD, 색상 지정 | +| P-005 | 검색 결과 | `/search` | 제목/내용/태그 기반 검색 결과 | + +--- + +## P-001: 대시보드 (`/`) + +### 레이아웃 + +[로고 todos2] | [검색바 _______________] | [알림] +● 대시보드 +할일 목록 +카테고리 관리 +카테고리 +업무 (12) +개인 (8) +학습 (5) +인기 태그 +#긴급 +#회의 +#프로젝트 +전체 할일 50 +완료 30 +미완료 20 +완료율 60% +[카테고리별 분포 - 도넛 차트] 업무 40% | 개인 30% 학습 20% | 기타 10% +[우선순위별 현황 - 막대 차트] high: 10 | medium: 25 | low: 15 +[마감 임박 할일] 1. API 문서 작성 (D-1) 2. 디자인 리뷰 (D-2) 3. 테스트 코드 (D-3) + +| 컴포넌트 | 기능 | 상태 | +| --- | --- | --- | +| StatsCards | 전체/완료/미완료/완료율 카드 | loading, data, empty | +| CategoryChart | 카테고리별 도넛 차트 | loading, data, empty | +| PriorityChart | 우선순위별 막대 차트 | loading, data, empty | +| UpcomingDeadlines | 마감 임박 할일 Top 5 | loading, data, empty | +| Sidebar | 카테고리/태그 네비게이션 | default | + +### 컴포넌트 + +| 컴포넌트 | Props | 상태 | +|---------|-------|------| +| `StatsCards` | stats | loading, empty, data | +| `CategoryChart` | categoryData | loading, empty, data | +| `PriorityChart` | priorityData | loading, empty, data | +| `UpcomingDeadlines` | deadlines, onItemClick | loading, empty, data | +| `Sidebar` | categories, tags, activePath | default | + +### 인터랙션 + +| 트리거 | 동작 | 결과 | +|--------|------|------| +| 마감 임박 항목 클릭 | `router.push(/todos/{id})` | 해당 할일 상세 페이지로 이동 | +| 사이드바 카테고리 클릭 | `router.push(/todos?category_id={id})` | 해당 카테고리의 할일 목록으로 이동 | +| 사이드바 태그 클릭 | `router.push(/todos?tag={name})` | 해당 태그의 할일 목록으로 이동 | + +### 반응형: sm, md, lg + +--- + +## P-002: 할일 목록 (`/todos`) + +### 레이아웃 + +[로고 todos2] | [검색바 _______________] | [알림] +대시보드 +● 할일 목록 +카테고리 관리 +필터: +상태 ▾ +우선순위 ▾ +정렬 ▾ ++ 새 할일 +3개 선택됨 +일괄 완료 +카테고리 변경 +일괄 삭제 +☐ +API 문서 작성 +업무 +#긴급 +D-1 +[수정] [삭제] +☑ +회의록 정리 +업무 +#회의 +D-5 +[수정] [삭제] +☐ +Next.js 학습 +학습 +#학습 +D-7 +[수정] [삭제] +☑ +장보기 목록 작성 +개인 +#생활 +- +[수정] [삭제] +< 1 2 3 4 5 > + +| 컴포넌트 | 기능 | 상태 | +| --- | --- | --- | +| TodoFilter | 상태/우선순위/정렬 필터 | default, applied | +| TodoList | 할일 카드 리스트 | loading, empty, error, data | +| TodoCard | 개별 할일 행 | default, completed, overdue | +| BatchActions | 일괄 작업 바 | hidden, visible | +| TodoForm (Modal) | 할일 생성/수정 모달 | create, edit | +| Pagination | 페이지 네비게이션 | default | + +### 컴포넌트 + +| 컴포넌트 | Props | 상태 | +|---------|-------|------| +| `TodoFilter` | filters, onFilterChange | default, applied | +| `TodoList` | todos, selectedIds, onToggle, onSelect, onEdit, onDelete | loading, empty, error, data | +| `TodoCard` | todo, isSelected, onToggle, onSelect, onEdit, onDelete | default, completed, overdue | +| `BatchActions` | selectedIds, categories, onBatchComplete, onBatchDelete, onBatchMove | hidden, visible | +| `TodoForm` | mode, todo, categories, tags, onSubmit, onClose | create, edit | +| `Pagination` | currentPage, totalPages, onPageChange | default | + +### 인터랙션 + +| 트리거 | 동작 | 결과 | +|--------|------|------| +| "+ 새 할일" 버튼 클릭 | `openTodoForm(mode='create')` | 할일 생성 모달 열림 | +| 체크박스 클릭 | `toggleTodo(id)` | 완료 상태 토글 | +| 행 선택 체크박스 | `toggleSelect(id)` | 일괄 작업 대상에 추가/제거 | +| 필터 변경 | `applyFilter(filters)` | 목록 재조회 | +| "일괄 완료" 클릭 | `batchComplete(selectedIds)` | 선택된 할일 일괄 완료 | +| "일괄 삭제" 클릭 | `batchDelete(selectedIds)` | 확인 후 일괄 삭제 | +| "카테고리 변경" 클릭 | `batchMoveCategory(selectedIds, categoryId)` | 카테고리 선택 후 변경 | +| 태그 뱃지 클릭 | `applyFilter({tag: tagName})` | 해당 태그로 필터링 | + +### 반응형: sm, md, lg + +--- + +## P-003: 할일 상세/편집 (`/todos/[id]`) + +### 레이아웃 + +[로고 todos2] | [검색바] | [알림] +(Sidebar) +할일 목록 > API 문서 작성 +제목 * +API 문서 작성 +내용 +Swagger UI 기반 API 문서를 작성하고 엔드포인트별 요청/응답 예시를 추가한다. +카테고리 +업무 ▾ +우선순위 +높음 ▾ +마감일 +2026-02-11 📅 +태그 +#긴급 × +#문서 × +태그 입력 (자동완성)... +취소 +저장 + +| 컴포넌트 | 기능 | 상태 | +| --- | --- | --- | +| TodoDetailForm | 할일 상세 폼 | loading, view, edit, saving | +| CategorySelect | 카테고리 드롭다운 | default | +| PrioritySelect | 우선순위 드롭다운 (색상) | default | +| DatePicker | 달력 마감일 선택 | default, open | +| TagInput | 태그 입력 + 자동완성 + 뱃지 | default, suggesting | + +### 컴포넌트 + +| 컴포넌트 | Props | 상태 | +|---------|-------|------| +| `TodoDetailForm` | todo, categories, tags, onSave, onCancel, onDelete | loading, view, edit, saving | +| `CategorySelect` | categories, selectedId, onChange | default | +| `PrioritySelect` | selectedPriority, onChange | default | +| `DatePicker` | selectedDate, onChange | default, open | +| `TagInput` | tags, suggestions, onAdd, onRemove | default, suggesting | + +### 인터랙션 + +| 트리거 | 동작 | 결과 | +|--------|------|------| +| "저장" 버튼 클릭 | `updateTodo(id, formData)` | 할일 업데이트 후 성공 토스트 | +| "취소" 버튼 클릭 | `router.back()` | 이전 페이지로 이동 | +| 태그 입력 키워드 타이핑 | `fetchTagSuggestions(keyword)` | 자동완성 드롭다운 표시 | +| 태그 뱃지 × 클릭 | `removeTag(tagName)` | 태그 제거 | +| 달력 아이콘 클릭 | `openDatePicker()` | 달력 팝업 표시 | + +### 반응형: sm, md, lg + +--- + +## P-004: 카테고리 관리 (`/categories`) + +### 레이아웃 + +[로고 todos2] | [검색바] | [알림] +대시보드 +할일 목록 +● 카테고리 관리 +카테고리 관리 ++ 새 카테고리 +업무 +12개 할일 +[색상] [수정] [삭제] +개인 +8개 할일 +[색상] [수정] [삭제] +학습 +5개 할일 +[색상] [수정] [삭제] +건강 +3개 할일 +[색상] [수정] [삭제] +새 카테고리 이름... +추가 + +| 컴포넌트 | 기능 | 상태 | +| --- | --- | --- | +| CategoryList | 카테고리 목록 | loading, empty, data | +| CategoryItem | 개별 카테고리 행 | default, editing | +| CategoryForm | 카테고리 생성/수정 인라인 폼 | create, edit | +| ColorPicker | 카테고리 색상 선택기 | default, open | + +### 컴포넌트 + +| 컴포넌트 | Props | 상태 | +|---------|-------|------| +| `CategoryList` | categories, onEdit, onDelete | loading, empty, data | +| `CategoryItem` | category, onEdit, onDelete | default, editing | +| `CategoryForm` | mode, category, onSubmit, onCancel | create, edit | +| `ColorPicker` | selectedColor, onChange | default, open | + +### 인터랙션 + +| 트리거 | 동작 | 결과 | +|--------|------|------| +| "+ 새 카테고리" 버튼 클릭 | `showCategoryForm(mode='create')` | 인라인 생성 폼 표시 | +| "추가" 버튼 클릭 | `createCategory({name, color})` | 카테고리 생성 후 목록 갱신 | +| "수정" 클릭 | `showCategoryForm(mode='edit', category)` | 해당 행이 수정 폼으로 전환 | +| "삭제" 클릭 | `deleteCategory(id)` | 확인 다이얼로그 후 삭제 | +| 색상 변경 클릭 | `openColorPicker(category)` | 색상 선택기 팝업 | + +### 반응형: sm, md, lg + +--- + +## P-005: 검색 결과 (`/search`) + +### 레이아웃 + +todos2 +API 문서 [×] +[알림] +(Sidebar) +"API 문서" 검색 결과 (3건) +API 문서 작성 +Swagger UI 기반 API 문서를 작성하고... +업무 +D-1 +API 문서 리뷰 +팀원들과 API 문서 리뷰 미팅을... +업무 +D-5 +REST API 문서화 학습 +OpenAPI 스펙과 자동화 도구를... +학습 +D-14 +* 결과 없을 때: "검색 결과가 없습니다. 다른 키워드로 검색해보세요." + +| 컴포넌트 | 기능 | 상태 | +| --- | --- | --- | +| SearchBar | 헤더 내 검색 입력 + 클리어 | default, active, has_query | +| SearchResults | 검색 결과 리스트 | loading, empty, data | +| SearchResultItem | 개별 결과 (제목 하이라이트, 설명) | default | +| Pagination | 결과 페이지네이션 | default | + +### 컴포넌트 + +| 컴포넌트 | Props | 상태 | +|---------|-------|------| +| `SearchBar` | query, onSearch, onClear | default, active, has_query | +| `SearchResults` | results, query, total, onItemClick | loading, empty, data | +| `SearchResultItem` | result, query, onClick | default | +| `Pagination` | currentPage, totalPages, onPageChange | default | + +### 인터랙션 + +| 트리거 | 동작 | 결과 | +|--------|------|------| +| 검색바에 키워드 입력 후 Enter | `search(query)` | 검색 API 호출 후 결과 표시 | +| 검색바 × 버튼 클릭 | `clearSearch()` | 검색어 클리어, 이전 페이지로 이동 | +| 검색 결과 항목 클릭 | `router.push(/todos/{id})` | 해당 할일 상세 페이지로 이동 | +| 페이지 번호 클릭 | `search(query, page)` | 해당 페이지의 검색 결과 | + +### 반응형: sm, md, lg diff --git a/docs/SCREEN_DESIGN.pptx b/docs/SCREEN_DESIGN.pptx new file mode 100644 index 0000000000000000000000000000000000000000..6be6f2bca5d88970d92838947d4aeb0f45084575 GIT binary patch literal 56363 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 literal 0 HcmV?d00001 diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md new file mode 100644 index 0000000..fe382d8 --- /dev/null +++ b/docs/TEST_REPORT.md @@ -0,0 +1,320 @@ +# todos2 -- 테스트 보고서 + +> 테스트 일시: 2026-02-10 10:30 KST +> 테스트 환경: macOS Darwin 25.2.0, Python 3.11, Node 20 +> 테스터: Senior System Tester + DevOps (Claude Opus 4.6) + +--- + +## 1. 백엔드 테스트 + +### 1.1 구문 검증 (Python AST) + +| 파일 | 결과 | +|------|------| +| `app/__init__.py` | OK | +| `app/config.py` | OK | +| `app/database.py` | OK | +| `app/main.py` | OK | +| `app/models/__init__.py` | OK | +| `app/models/category.py` | OK | +| `app/models/common.py` | OK | +| `app/models/todo.py` | OK | +| `app/routers/__init__.py` | OK | +| `app/routers/categories.py` | OK | +| `app/routers/dashboard.py` | OK | +| `app/routers/search.py` | OK | +| `app/routers/tags.py` | OK | +| `app/routers/todos.py` | OK | +| `app/services/__init__.py` | OK | +| `app/services/category_service.py` | OK | +| `app/services/dashboard_service.py` | OK | +| `app/services/search_service.py` | OK | +| `app/services/todo_service.py` | OK | + +**결과: 19/19 파일 통과 (100%)** + +### 1.2 Import 정합성 + +| 파일 | Import | 참조 대상 | 결과 | +|------|--------|----------|------| +| `app/main.py` | `from contextlib import asynccontextmanager` | stdlib | OK | +| `app/main.py` | `from datetime import datetime` | stdlib | OK | +| `app/main.py` | `from fastapi import FastAPI` | fastapi (requirements.txt) | OK | +| `app/main.py` | `from fastapi.middleware.cors import CORSMiddleware` | fastapi | OK | +| `app/main.py` | `from app.config import get_settings` | app/config.py | OK | +| `app/main.py` | `from app.database import connect_db, disconnect_db` | app/database.py | OK | +| `app/main.py` | `from app.routers import todos, categories, tags, search, dashboard` | app/routers/*.py | OK | +| `app/config.py` | `from pydantic_settings import BaseSettings` | pydantic-settings (requirements.txt) | OK | +| `app/config.py` | `from pydantic import Field` | pydantic (requirements.txt) | OK | +| `app/config.py` | `from functools import lru_cache` | stdlib | OK | +| `app/database.py` | `from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase` | motor (requirements.txt) | OK | +| `app/database.py` | `import redis.asyncio as aioredis` | redis (requirements.txt) | OK | +| `app/database.py` | `from app.config import get_settings` | app/config.py | OK | +| `app/models/common.py` | `from bson import ObjectId` | pymongo (requirements.txt) | OK | +| `app/models/common.py` | `from pydantic import BaseModel, BeforeValidator` | pydantic | OK | +| `app/models/todo.py` | `from datetime import datetime` | stdlib | OK | +| `app/models/todo.py` | `from enum import Enum` | stdlib | OK | +| `app/models/todo.py` | `from pydantic import BaseModel, Field, field_validator` | pydantic | OK | +| `app/models/todo.py` | `from bson import ObjectId` (inside validator) | pymongo | OK | +| `app/models/category.py` | `from datetime import datetime` | stdlib | OK | +| `app/models/category.py` | `from pydantic import BaseModel, Field, field_validator` | pydantic | OK | +| `app/models/__init__.py` | `from app.models.common import ...` | app/models/common.py | OK | +| `app/models/__init__.py` | `from app.models.todo import ...` | app/models/todo.py | OK | +| `app/models/__init__.py` | `from app.models.category import ...` | app/models/category.py | OK | +| `app/routers/__init__.py` | `from app.routers import todos, categories, tags, search, dashboard` | app/routers/*.py | OK | +| `app/routers/todos.py` | `from fastapi import APIRouter, Depends, Query, Response, status` | fastapi | OK | +| `app/routers/todos.py` | `from app.database import get_database, get_redis` | app/database.py | OK | +| `app/routers/todos.py` | `from app.models.todo import ...` | app/models/todo.py | OK | +| `app/routers/todos.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK | +| `app/routers/categories.py` | `from app.database import get_database, get_redis` | app/database.py | OK | +| `app/routers/categories.py` | `from app.models.category import ...` | app/models/category.py | OK | +| `app/routers/categories.py` | `from app.services.category_service import CategoryService` | app/services/category_service.py | OK | +| `app/routers/tags.py` | `from app.database import get_database` | app/database.py | OK | +| `app/routers/tags.py` | `from app.models.todo import TagInfo` | app/models/todo.py | OK | +| `app/routers/tags.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK | +| `app/routers/search.py` | `from app.database import get_database` | app/database.py | OK | +| `app/routers/search.py` | `from app.models.todo import SearchResponse` | app/models/todo.py | OK | +| `app/routers/search.py` | `from app.services.search_service import SearchService` | app/services/search_service.py | OK | +| `app/routers/dashboard.py` | `from app.database import get_database, get_redis` | app/database.py | OK | +| `app/routers/dashboard.py` | `from app.models.todo import DashboardStats` | app/models/todo.py | OK | +| `app/routers/dashboard.py` | `from app.services.dashboard_service import DashboardService` | app/services/dashboard_service.py | OK | +| `app/services/__init__.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK | +| `app/services/__init__.py` | `from app.services.category_service import CategoryService` | app/services/category_service.py | OK | +| `app/services/__init__.py` | `from app.services.search_service import SearchService` | app/services/search_service.py | OK | +| `app/services/__init__.py` | `from app.services.dashboard_service import DashboardService` | app/services/dashboard_service.py | OK | +| `app/services/todo_service.py` | `from bson import ObjectId` | pymongo | OK | +| `app/services/todo_service.py` | `from fastapi import HTTPException` | fastapi | OK | +| `app/services/todo_service.py` | `from motor.motor_asyncio import AsyncIOMotorDatabase` | motor | OK | +| `app/services/todo_service.py` | `import redis.asyncio as aioredis` | redis | OK | +| `app/services/todo_service.py` | `from app.models.todo import ...` | app/models/todo.py | OK | +| `app/services/category_service.py` | `from app.models.category import ...` | app/models/category.py | OK | +| `app/services/search_service.py` | `from app.models.todo import TodoResponse, SearchResponse` | app/models/todo.py | OK | +| `app/services/dashboard_service.py` | `import json` | stdlib | OK | +| `app/services/dashboard_service.py` | `from app.models.todo import ...` | app/models/todo.py | OK | + +**결과: 53/53 import 통과 (100%)** + +### 1.3 API 엔드포인트 매핑 + +| 기능 (FEATURE_SPEC) | API 엔드포인트 | Router 파일 | 결과 | +|---------------------|---------------|------------|------| +| F-001: 할일 생성 | `POST /api/todos` | `routers/todos.py` (L62-68) | OK | +| F-002: 할일 목록 조회 | `GET /api/todos` | `routers/todos.py` (L37-59) | OK | +| F-003: 할일 상세 조회 | `GET /api/todos/{id}` | `routers/todos.py` (L71-77) | OK | +| F-004: 할일 수정 | `PUT /api/todos/{id}` | `routers/todos.py` (L80-87) | OK | +| F-005: 할일 삭제 | `DELETE /api/todos/{id}` | `routers/todos.py` (L90-97) | OK | +| F-006: 할일 완료 토글 | `PATCH /api/todos/{id}/toggle` | `routers/todos.py` (L100-106) | OK | +| F-007: 카테고리 생성 | `POST /api/categories` | `routers/categories.py` (L29-35) | OK | +| F-008: 카테고리 목록 조회 | `GET /api/categories` | `routers/categories.py` (L21-26) | OK | +| F-009: 카테고리 수정 | `PUT /api/categories/{id}` | `routers/categories.py` (L38-45) | OK | +| F-010: 카테고리 삭제 | `DELETE /api/categories/{id}` | `routers/categories.py` (L48-55) | OK | +| F-011: 태그 부여 | F-001/F-004의 `tags` 필드 | `models/todo.py` + `services/todo_service.py` | OK | +| F-012: 태그별 필터링 | `GET /api/todos?tag=...` | `routers/todos.py` (L44) | OK | +| F-013: 태그 목록 조회 | `GET /api/tags` | `routers/tags.py` (L10-16) | OK | +| F-014: 우선순위 설정 | F-001/F-004의 `priority` 필드 | `models/todo.py` Priority enum | OK | +| F-015: 마감일 설정 | F-001/F-004의 `due_date` 필드 | `models/todo.py` | OK | +| F-016: 마감일 알림 표시 | 프론트엔드 UI 로직 | `lib/utils.ts` getDueDateStatus/Label/Color | OK | +| F-017: 검색 | `GET /api/search?q=...` | `routers/search.py` (L10-19) | OK | +| F-018: 대시보드 통계 | `GET /api/dashboard/stats` | `routers/dashboard.py` (L10-17) | OK | +| F-019: 일괄 완료 처리 | `POST /api/todos/batch` (action=complete) | `routers/todos.py` (L28-34) | OK | +| F-020: 일괄 삭제 | `POST /api/todos/batch` (action=delete) | `routers/todos.py` (L28-34) | OK | +| F-021: 일괄 카테고리 변경 | `POST /api/todos/batch` (action=move_category) | `routers/todos.py` (L28-34) | OK | + +**결과: 21/21 기능 매핑 완료 (100%)** + +--- + +## 2. 프론트엔드 테스트 + +### 2.1 빌드 검증 + +- Next.js 빌드: **OK** (이미 성공 확인됨) + +### 2.2 화면설계서 컴포넌트 매핑 + +#### P-001: 대시보드 (`/`) + +| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 | +|--------------------------|------|------| +| `StatsCards` | `src/components/dashboard/StatsCards.tsx` | OK | +| `CategoryChart` | `src/components/dashboard/CategoryChart.tsx` | OK | +| `PriorityChart` | `src/components/dashboard/PriorityChart.tsx` | OK | +| `UpcomingDeadlines` | `src/components/dashboard/UpcomingDeadlines.tsx` | OK | +| `Sidebar` | `src/components/layout/Sidebar.tsx` | OK | +| 페이지 | `src/app/page.tsx` | OK | + +#### P-002: 할일 목록 (`/todos`) + +| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 | +|--------------------------|------|------| +| `TodoFilter` | `src/components/todos/TodoFilter.tsx` | OK | +| `TodoList` | `src/components/todos/TodoList.tsx` | OK | +| `TodoCard` | `src/components/todos/TodoCard.tsx` | OK | +| `BatchActions` | `src/components/todos/BatchActions.tsx` | OK | +| `TodoForm` (Modal) | `src/components/todos/TodoForm.tsx` | OK | +| `Pagination` | `src/components/common/Pagination.tsx` | OK | +| 페이지 | `src/app/todos/page.tsx` | OK | + +#### P-003: 할일 상세/편집 (`/todos/[id]`) + +| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 | +|--------------------------|------|------| +| `TodoDetailForm` | `src/components/todos/TodoDetailForm.tsx` | OK | +| `CategorySelect` (inline select) | `TodoDetailForm.tsx` 내 `` (L162-174) | OK | +| `DatePicker` | `src/components/common/DatePicker.tsx` | OK | +| `TagInput` | `src/components/common/TagInput.tsx` | OK | +| 페이지 | `src/app/todos/[id]/page.tsx` | OK | + +#### P-004: 카테고리 관리 (`/categories`) + +| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 | +|--------------------------|------|------| +| `CategoryList` | `src/components/categories/CategoryList.tsx` | OK | +| `CategoryItem` | `src/components/categories/CategoryItem.tsx` | OK | +| `CategoryForm` | `src/components/categories/CategoryForm.tsx` | OK | +| `ColorPicker` | `src/components/categories/ColorPicker.tsx` | OK | +| 페이지 | `src/app/categories/page.tsx` | OK | + +#### P-005: 검색 결과 (`/search`) + +| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 | +|--------------------------|------|------| +| `SearchBar` | `src/components/search/SearchBar.tsx` | OK | +| `SearchResults` | `src/components/search/SearchResults.tsx` | OK | +| `SearchResultItem` | `SearchResults.tsx` 내 인라인 구현 (검색 결과 항목 렌더링 + 하이라이트) | OK | +| `Pagination` | `src/components/common/Pagination.tsx` (공유) | OK | +| 페이지 | `src/app/search/page.tsx` | OK | + +**결과: 5개 페이지, 27개 컴포넌트 모두 매핑 완료 (100%)** + +### 2.3 타입 정합성 + +- `types/index.ts`: 14개 인터페이스/타입 정의 (Todo, TodoCreate, TodoUpdate, TodoListResponse, ToggleResponse, BatchRequest, BatchResponse, Category, CategoryCreate, CategoryUpdate, TagInfo, SearchResponse, DashboardStats, TodoFilters) +- `hooks/useTodos.ts`: types/index.ts의 Todo, TodoCreate, TodoUpdate, TodoListResponse, TodoFilters, ToggleResponse, BatchRequest, BatchResponse 사용 - **OK** +- `hooks/useCategories.ts`: types/index.ts의 Category, CategoryCreate, CategoryUpdate 사용 - **OK** +- `hooks/useDashboard.ts`: types/index.ts의 DashboardStats 사용 - **OK** +- `hooks/useSearch.ts`: types/index.ts의 SearchResponse 사용 - **OK** +- `hooks/useTags.ts`: types/index.ts의 TagInfo 사용 - **OK** +- `lib/api.ts`: types/index.ts의 ApiError 사용 - **OK** +- `store/uiStore.ts`: types/index.ts의 TodoFilters, SortField, SortOrder 사용 - **OK** +- 컴포넌트 -> hooks -> types 연결: **OK** + +### 2.4 공통 컴포넌트 + +| 컴포넌트 | 파일 | 용도 | 결과 | +|---------|------|------|------| +| `Header` | `src/components/layout/Header.tsx` | 상단 헤더 (로고, 검색바, 알림) | OK | +| `MainLayout` | `src/components/layout/MainLayout.tsx` | 전체 레이아웃 래퍼 | OK | +| `Sidebar` | `src/components/layout/Sidebar.tsx` | 사이드바 네비게이션 | OK | +| `QueryProvider` | `src/components/providers/QueryProvider.tsx` | Tanstack Query 프로바이더 | OK | +| `Pagination` | `src/components/common/Pagination.tsx` | 페이지네이션 | OK | +| `PriorityBadge` | `src/components/common/PriorityBadge.tsx` | 우선순위 뱃지 | OK | +| `TagBadge` | `src/components/common/TagBadge.tsx` | 태그 뱃지 | OK | +| `TagInput` | `src/components/common/TagInput.tsx` | 태그 입력 + 자동완성 | OK | +| `DatePicker` | `src/components/common/DatePicker.tsx` | 날짜 선택 | OK | + +--- + +## 3. Docker 검증 + +### 3.1 docker-compose.yml 검증 + +- **문법 검증**: `docker compose config --quiet` -- **OK** (오류 없음) +- **서비스 구성**: + +| 서비스 | 이미지 | 포트 | healthcheck | volumes | networks | 결과 | +|--------|--------|------|-------------|---------|----------|------| +| mongodb | `mongo:7.0` | 27017:27017 | `mongosh --eval` | mongodb_data:/data/db | app-network | OK | +| redis | `redis:7-alpine` | 6379:6379 | `redis-cli ping` | redis_data:/data | app-network | OK | +| backend | `./backend/Dockerfile` | 8000:8000 | - | - | app-network | OK | +| frontend | `./frontend/Dockerfile` | 3000:3000 | - | - | app-network | OK | + +- **환경변수**: MONGODB_URL, DB_NAME, REDIS_URL (backend) - **OK** +- **의존성**: backend -> mongodb (service_healthy), redis (service_healthy) - **OK** +- **네트워크**: `app-network` (bridge) - **OK** +- **볼륨**: `mongodb_data`, `redis_data` - **OK** + +### 3.2 Dockerfile 검증 + +| 서비스 | Dockerfile | 베이스 이미지 | 빌드 단계 | CMD | 결과 | +|--------|-----------|------------|----------|-----|------| +| backend | `backend/Dockerfile` | `python:3.11-slim` | apt curl, pip install, COPY app/ | `uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload` | OK | +| frontend | `frontend/Dockerfile` | `node:20-alpine` | npm ci, COPY ., npm run build | `npm run start` | OK | + +### 3.3 .env 파일 검증 + +| 변수 | 값 | 사용처 | 결과 | +|------|-----|--------|------| +| `PROJECT_NAME` | `todos2` | container_name 프리픽스 | OK | +| `MONGO_USER` | `admin` | MONGO_INITDB_ROOT_USERNAME | OK | +| `MONGO_PASSWORD` | `password123` | MONGO_INITDB_ROOT_PASSWORD | OK | +| `MONGO_PORT` | `27017` | mongodb 포트 매핑 | OK | +| `REDIS_PORT` | `6379` | redis 포트 매핑 | OK | +| `DB_NAME` | `todos2` | MongoDB 데이터베이스 이름 | OK (수정됨) | +| `BACKEND_PORT` | `8000` | backend 포트 매핑 | OK | +| `FRONTEND_PORT` | `3000` | frontend 포트 매핑 | OK | + +--- + +## 4. 종합 결과 + +| 항목 | 테스트 수 | 통과 | 실패 | 통과율 | +|------|----------|------|------|--------| +| 백엔드 구문 (AST) | 19 | 19 | 0 | 100% | +| 백엔드 Import | 53 | 53 | 0 | 100% | +| API 엔드포인트 매핑 | 21 | 21 | 0 | 100% | +| 프론트엔드 빌드 | 1 | 1 | 0 | 100% | +| 화면 컴포넌트 매핑 | 27 | 27 | 0 | 100% | +| Docker 서비스 | 4 | 4 | 0 | 100% | +| Docker 파일 | 2 | 2 | 0 | 100% | +| .env 변수 | 8 | 8 | 0 | 100% | +| **합계** | **135** | **135** | **0** | **100%** | + +--- + +## 5. 발견된 이슈 및 수정 사항 + +### 5.1 수정 완료 + +| # | 파일 | 이슈 | 수정 내역 | +|---|------|------|----------| +| 1 | `.env` | `DB_NAME=app_db` (요구사항: `todos2`) | `DB_NAME=todos2`로 수정 | + +**설명**: `.env` 파일의 `DB_NAME` 값이 `app_db`로 설정되어 있었으나, 프로젝트 요구사항 및 `config.py`의 기본값(`todos2`)과 불일치. `todos2`로 수정하여 docker-compose.yml의 `DB_NAME=${DB_NAME:-app_db}` 환경변수가 올바르게 `todos2`를 참조하도록 변경. + +### 5.2 참고 사항 (이슈 아님) + +| # | 항목 | 설명 | +|---|------|------| +| 1 | `routers/tags.py` | `TodoService(db)` -- redis 없이 초기화. 태그 조회는 캐시 불필요하므로 정상 동작. TodoService 생성자에서 `redis_client`는 `Optional[aioredis.Redis] = None`이므로 문제 없음. | +| 2 | `SearchService` | `_populate_categories_bulk` 메서드가 `TodoService`와 중복 존재. 리팩토링 여지가 있으나 기능상 문제 없음. | +| 3 | `SCREEN_DESIGN.md`의 `SearchResultItem` | 별도 컴포넌트가 아닌 `SearchResults.tsx` 내부 인라인 구현. 기능 동작에는 문제 없음. | +| 4 | backend Dockerfile | `--reload` 플래그가 프로덕션에서는 제거되어야 하지만, 현재 개발 환경 설정이므로 허용. | + +--- + +## 6. 아키텍처 준수 확인 + +| 아키텍처 항목 (ARCHITECTURE.md) | 구현 상태 | 결과 | +|-------------------------------|----------|------| +| Frontend: Next.js App Router | `src/app/` 디렉토리 사용 | OK | +| Frontend: TypeScript | `.tsx`, `.ts` 파일 사용 | OK | +| Frontend: Tailwind CSS | `globals.css` + className 유틸리티 | OK | +| Frontend: Recharts | `CategoryChart.tsx`, `PriorityChart.tsx`에서 사용 | OK | +| Frontend: Tanstack Query | `QueryProvider.tsx` + 5개 hooks | OK | +| Frontend: Zustand | `store/uiStore.ts` | OK | +| Backend: FastAPI | `main.py` + 5개 라우터 | OK | +| Backend: Motor (MongoDB) | `database.py` AsyncIOMotorClient | OK | +| Backend: Pydantic v2 | `models/` BaseModel, field_validator | OK | +| Backend: Redis (aioredis) | `database.py` redis.asyncio | OK | +| Backend: 3-Layer (Router -> Service -> DB) | routers/ -> services/ -> database.py | OK | +| Database: MongoDB 7.0 | docker-compose.yml `mongo:7.0` | OK | +| Database: Redis 7 | docker-compose.yml `redis:7-alpine` | OK | +| Infra: Docker Compose | `docker-compose.yml` 4 서비스 | OK | +| 캐싱: Redis TTL 60s | `dashboard_service.py` DASHBOARD_CACHE_TTL=60 | OK | +| 캐시 무효화: CUD 시 | `todo_service.py`, `category_service.py` _invalidate_cache() | OK | +| Text Search Index | `database.py` create_indexes() title/content/tags | OK | + +**아키텍처 준수율: 17/17 (100%)** diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f95d3f9 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,39 @@ +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..61478ef --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.json* ./ +RUN npm ci + +# Copy source code +COPY . . + +# Build +RUN npm run build + +# Expose port +EXPOSE 3000 + +# Start production server +CMD ["npm", "run", "start"] diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..c85fb67 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..12911b1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6439 @@ +{ + "name": "todos2-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "todos2-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.468.0", + "next": "15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.0", + "tailwind-merge": "^2.6.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.1.0", + "postcss": "^8", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "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.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "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.1", + "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/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "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/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.0.tgz", + "integrity": "sha512-UcCO481cROsqJuszPPXJnb7GGuLq617ve4xuAyyNG4VSSocJNtMU5Fsx+Lp6mlN8c7W58aZLc5y6D/2xNmaK+w==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.0.tgz", + "integrity": "sha512-+jPT0h+nelBT6HC9ZCHGc7DgGVy04cv4shYdAe6tKlEbjQUtwU3LzQhzbDHQyY2m6g39m6B0kOFVuLGBrxxbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.0.tgz", + "integrity": "sha512-ZU8d7xxpX14uIaFC3nsr4L++5ZS/AkWDm1PzPO6gD9xWhFkOj2hzSbSIxoncsnlJXB1CbLOfGVN4Zk9tg83PUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.0.tgz", + "integrity": "sha512-DQ3RiUoW2XC9FcSM4ffpfndq1EsLV0fj0/UY33i7eklW5akPUCo6OX2qkcLXZ3jyPdo4sf2flwAED3AAq3Om2Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.0.tgz", + "integrity": "sha512-M+vhTovRS2F//LMx9KtxbkWk627l5Q7AqXWWWrfIzNIaUFiz2/NkOFkxCFyNyGACi5YbA8aekzCLtbDyfF/v5Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.0.tgz", + "integrity": "sha512-Qn6vOuwaTCx3pNwygpSGtdIu0TfS1KiaYLYXLH5zq1scoTXdwYfdZtwvJTpB1WrLgiQE2Ne2kt8MZok3HlFqmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.0.tgz", + "integrity": "sha512-yeNh9ofMqzOZ5yTOk+2rwncBzucc6a1lyqtg8xZv0rH5znyjxHOWsoUtSq4cUTeeBIiXXX51QOOe+VoCjdXJRw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.0.tgz", + "integrity": "sha512-t9IfNkHQs/uKgPoyEtU912MG6a1j7Had37cSUyLTKx9MnUpjj+ZDKw9OyqTI9OwIIv0wmkr1pkZy+3T5pxhJPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.0.tgz", + "integrity": "sha512-WEAoHyG14t5sTavZa1c6BnOIEukll9iqFRTavqRVPfYmfegOAd5MaZfXgOGG6kGo1RduyGdTHD4+YZQSdsNZXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.0.tgz", + "integrity": "sha512-J1YdKuJv9xcixzXR24Dv+4SaDKc2jj31IVUEMdO5xJivMTXuE6MAdIi4qPjSymHuFG8O5wbfWKnhJUcHHpj5CA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", + "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "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/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", + "integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.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.55.0", + "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.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "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.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "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.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "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.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "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.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.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.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "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.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.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/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.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/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "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/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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", + "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/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/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "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-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "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/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/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "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/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "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/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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==", + "devOptional": true, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "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.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "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/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "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/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "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/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/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "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/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.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", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "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==", + "dev": true, + "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==", + "dev": true, + "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/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.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "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.2", + "@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-config-next": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.1.0.tgz", + "integrity": "sha512-gADO+nKVseGso3DtOrYX9H7TxB/MuX7AUYhMlvQMqLYvUWu4HrOQuU7cC1HW74tHIqkAvXdwgAz3TCbczzSEXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.1.0", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "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-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/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/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "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/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "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.4" + }, + "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==", + "dev": true, + "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/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=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/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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "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/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/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "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-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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/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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "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==", + "dev": true, + "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/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/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.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT", + "optional": true + }, + "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-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-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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-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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "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-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-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-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-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==", + "dev": true, + "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/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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-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": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "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/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "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/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "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/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/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "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/next": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/next/-/next-15.1.0.tgz", + "integrity": "sha512-QKhzt6Y8rgLNlj30izdMbxAwjHMFANnLwDwZ+WQh5sMhyt4lEBqDK9QpvWHtIM4rINKPoJ8aiRZKg5ULSybVHw==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "15.1.0", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.1.0", + "@next/swc-darwin-x64": "15.1.0", + "@next/swc-linux-arm64-gnu": "15.1.0", + "@next/swc-linux-arm64-musl": "15.1.0", + "@next/swc-linux-x64-gnu": "15.1.0", + "@next/swc-linux-x64-musl": "15.1.0", + "@next/swc-win32-arm64-msvc": "15.1.0", + "@next/swc-win32-x64-msvc": "15.1.0", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "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.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.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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/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/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==", + "dev": true, + "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", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/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/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/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "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-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-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/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/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "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/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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-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/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "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/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/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/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "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/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==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "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/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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "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.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "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/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/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..777862e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "todos2-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.468.0", + "next": "15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.0", + "tailwind-merge": "^2.6.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.1.0", + "postcss": "^8", + "tailwindcss": "^4", + "@tailwindcss/postcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..79bcf13 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/src/app/categories/page.tsx b/frontend/src/app/categories/page.tsx new file mode 100644 index 0000000..ec55f9c --- /dev/null +++ b/frontend/src/app/categories/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { Category, CategoryCreate, CategoryUpdate } from "@/types"; +import { + useCategoryList, + useCreateCategory, + useUpdateCategory, + useDeleteCategory, +} from "@/hooks/useCategories"; +import CategoryList from "@/components/categories/CategoryList"; +import CategoryForm from "@/components/categories/CategoryForm"; + +export default function CategoriesPage() { + const { data: categories, isLoading } = useCategoryList(); + const createCategory = useCreateCategory(); + const updateCategory = useUpdateCategory(); + const deleteCategory = useDeleteCategory(); + + const [showForm, setShowForm] = useState(false); + const [formMode, setFormMode] = useState<"create" | "edit">("create"); + const [editingCategory, setEditingCategory] = useState(null); + const [error, setError] = useState(""); + + const handleCreate = () => { + setFormMode("create"); + setEditingCategory(null); + setShowForm(true); + setError(""); + }; + + const handleEdit = (category: Category) => { + setFormMode("edit"); + setEditingCategory(category); + setShowForm(true); + setError(""); + }; + + const handleDelete = async (category: Category) => { + try { + await deleteCategory.mutateAsync(category.id); + } catch (err: unknown) { + const apiErr = err as { detail?: string }; + setError(apiErr.detail || "삭제에 실패했습니다"); + } + }; + + const handleSubmit = async (data: CategoryCreate | CategoryUpdate) => { + setError(""); + try { + if (formMode === "create") { + await createCategory.mutateAsync(data as CategoryCreate); + } else if (editingCategory) { + await updateCategory.mutateAsync({ + id: editingCategory.id, + data: data as CategoryUpdate, + }); + } + setShowForm(false); + setEditingCategory(null); + } catch (err: unknown) { + const apiErr = err as { detail?: string }; + setError(apiErr.detail || "저장에 실패했습니다"); + } + }; + + const handleCancel = () => { + setShowForm(false); + setEditingCategory(null); + setError(""); + }; + + return ( +
+ {/* Header */} +
+
+

카테고리 관리

+

+ 할일을 분류할 카테고리를 관리하세요 +

+
+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Create/Edit Form */} + {showForm && ( + + )} + + {/* Category List */} + +
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..9eb5848 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import QueryProvider from "@/components/providers/QueryProvider"; +import MainLayout from "@/components/layout/MainLayout"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "todos2 - 할일 관리", + description: "카테고리, 태그, 우선순위, 마감일을 갖춘 확장형 할일 관리 애플리케이션", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..8dfc5bf --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useDashboardStats } from "@/hooks/useDashboard"; +import StatsCards from "@/components/dashboard/StatsCards"; +import CategoryChart from "@/components/dashboard/CategoryChart"; +import PriorityChart from "@/components/dashboard/PriorityChart"; +import UpcomingDeadlines from "@/components/dashboard/UpcomingDeadlines"; + +export default function DashboardPage() { + const { data: stats, isLoading, isError } = useDashboardStats(); + + if (isError) { + return ( +
+

대시보드를 불러올 수 없습니다

+

+ 잠시 후 다시 시도해주세요 +

+
+ ); + } + + return ( +
+
+

대시보드

+

+ 할일 현황을 한눈에 확인하세요 +

+
+ + {/* Stats Cards */} + + + {/* Charts */} +
+ + +
+ + {/* Upcoming Deadlines */} + +
+ ); +} diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx new file mode 100644 index 0000000..a16e02d --- /dev/null +++ b/frontend/src/app/search/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState, useEffect, Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { useSearch } from "@/hooks/useSearch"; +import SearchResults from "@/components/search/SearchResults"; +import Pagination from "@/components/common/Pagination"; + +function SearchContent() { + const searchParams = useSearchParams(); + const queryParam = searchParams.get("q") || ""; + const [page, setPage] = useState(1); + + useEffect(() => { + setPage(1); + }, [queryParam]); + + const { data, isLoading } = useSearch(queryParam, page); + + const totalPages = data + ? Math.ceil(data.total / (data.limit || 20)) + : 0; + + return ( +
+
+

검색 결과

+
+ + + + {totalPages > 1 && ( + + )} +
+ ); +} + +export default function SearchPage() { + return ( + + 검색 중... + + } + > + + + ); +} diff --git a/frontend/src/app/todos/[id]/page.tsx b/frontend/src/app/todos/[id]/page.tsx new file mode 100644 index 0000000..033a08d --- /dev/null +++ b/frontend/src/app/todos/[id]/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useUIStore } from "@/store/uiStore"; + +export default function TodoDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const router = useRouter(); + const openTodoForm = useUIStore((s) => s.openTodoForm); + + useEffect(() => { + openTodoForm("edit", id); + router.replace("/todos"); + }, [id, router, openTodoForm]); + + return null; +} diff --git a/frontend/src/app/todos/page.tsx b/frontend/src/app/todos/page.tsx new file mode 100644 index 0000000..6714395 --- /dev/null +++ b/frontend/src/app/todos/page.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { Suspense, useState, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; +import { Plus, Loader2, List, BarChart3 } from "lucide-react"; +import { useUIStore } from "@/store/uiStore"; +import { + useTodoList, + useToggleTodo, + useDeleteTodo, + useBatchAction, + useTodoDetail, +} from "@/hooks/useTodos"; +import { cn } from "@/lib/utils"; +import TodoFilter from "@/components/todos/TodoFilter"; +import TodoList from "@/components/todos/TodoList"; +import TodoModal from "@/components/todos/TodoModal"; +import BatchActions from "@/components/todos/BatchActions"; +import Pagination from "@/components/common/Pagination"; +import GanttChart from "@/components/todos/GanttChart"; + +function TodosContent() { + const searchParams = useSearchParams(); + const [viewMode, setViewMode] = useState<"list" | "gantt">("list"); + const { + filters, + setFilter, + setFilters, + selectedIds, + toggleSelect, + clearSelection, + todoFormOpen, + todoFormMode, + editingTodoId, + openTodoForm, + closeTodoForm, + } = useUIStore(); + + // Apply URL query params as filters + useEffect(() => { + const categoryId = searchParams.get("category_id"); + const tag = searchParams.get("tag"); + + if (categoryId || tag) { + setFilters({ + ...(categoryId ? { category_id: categoryId } : {}), + ...(tag ? { tag } : {}), + }); + } + }, [searchParams, setFilters]); + + // Queries + const { + data: todosData, + isLoading, + isError, + error, + } = useTodoList(filters); + + // Get detail for editing + const { data: editingTodo } = useTodoDetail(editingTodoId || ""); + + // Mutations + const toggleTodo = useToggleTodo(); + const deleteTodo = useDeleteTodo(); + const batchAction = useBatchAction(); + + const handleToggle = useCallback( + (id: string) => { + toggleTodo.mutate(id); + }, + [toggleTodo] + ); + + const handleDelete = useCallback( + (id: string) => { + if (window.confirm("이 할일을 삭제하시겠습니까?")) { + deleteTodo.mutate(id); + } + }, + [deleteTodo] + ); + + const handleEdit = useCallback( + (id: string) => { + openTodoForm("edit", id); + }, + [openTodoForm] + ); + + const handleTagClick = useCallback( + (tag: string) => { + setFilter("tag", tag); + }, + [setFilter] + ); + + const handleBatchComplete = useCallback(async () => { + await batchAction.mutateAsync({ + action: "complete", + ids: selectedIds, + }); + clearSelection(); + }, [batchAction, selectedIds, clearSelection]); + + const handleBatchDelete = useCallback(async () => { + await batchAction.mutateAsync({ + action: "delete", + ids: selectedIds, + }); + clearSelection(); + }, [batchAction, selectedIds, clearSelection]); + + const handleBatchMove = useCallback( + async (categoryId: string | null) => { + await batchAction.mutateAsync({ + action: "move_category", + ids: selectedIds, + category_id: categoryId, + }); + clearSelection(); + }, + [batchAction, selectedIds, clearSelection] + ); + + return ( +
+ {/* Header */} +
+
+

할일 목록

+

+ {todosData ? `총 ${todosData.total}개` : ""} +

+
+
+ {/* View toggle */} +
+ + +
+ +
+
+ + {viewMode === "list" ? ( + <> + {/* Filters */} + + + {/* Batch Actions */} + + + {/* Todo List */} + + + {/* Pagination */} + {todosData && todosData.total_pages > 1 && ( + setFilter("page", page)} + /> + )} + + ) : ( + + )} + + {/* Create/Edit Modal */} + +
+ ); +} + +export default function TodosPage() { + return ( + + + 로딩 중... + + } + > + + + ); +} diff --git a/frontend/src/components/categories/CategoryForm.tsx b/frontend/src/components/categories/CategoryForm.tsx new file mode 100644 index 0000000..8dc97f1 --- /dev/null +++ b/frontend/src/components/categories/CategoryForm.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Plus, Save, X } from "lucide-react"; +import { Category, CategoryCreate, CategoryUpdate } from "@/types"; +import ColorPicker from "./ColorPicker"; + +interface CategoryFormProps { + mode: "create" | "edit"; + category?: Category | null; + onSubmit: (data: CategoryCreate | CategoryUpdate) => void; + onCancel: () => void; + isSubmitting?: boolean; +} + +export default function CategoryForm({ + mode, + category, + onSubmit, + onCancel, + isSubmitting, +}: CategoryFormProps) { + const [name, setName] = useState(""); + const [color, setColor] = useState("#6B7280"); + const [error, setError] = useState(""); + + useEffect(() => { + if (mode === "edit" && category) { + setName(category.name); + setColor(category.color); + } else { + setName(""); + setColor("#6B7280"); + } + setError(""); + }, [mode, category]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + const trimmedName = name.trim(); + if (!trimmedName) { + setError("카테고리 이름을 입력해주세요"); + return; + } + if (trimmedName.length > 50) { + setError("이름은 50자 이하로 입력해주세요"); + return; + } + + onSubmit({ name: trimmedName, color }); + }; + + return ( +
+
+ + setName(e.target.value)} + placeholder="카테고리 이름..." + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + maxLength={50} + autoFocus + /> + {error &&

{error}

} +
+ + + +
+ + +
+ + ); +} diff --git a/frontend/src/components/categories/CategoryItem.tsx b/frontend/src/components/categories/CategoryItem.tsx new file mode 100644 index 0000000..e60f814 --- /dev/null +++ b/frontend/src/components/categories/CategoryItem.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Pencil, Trash2 } from "lucide-react"; +import { Category } from "@/types"; + +interface CategoryItemProps { + category: Category; + onEdit: (category: Category) => void; + onDelete: (category: Category) => void; +} + +export default function CategoryItem({ + category, + onEdit, + onDelete, +}: CategoryItemProps) { + const handleDelete = () => { + const message = + category.todo_count > 0 + ? `"${category.name}" 카테고리를 삭제하시겠습니까?\n이 카테고리에 속한 ${category.todo_count}개의 할일이 "미분류"로 변경됩니다.` + : `"${category.name}" 카테고리를 삭제하시겠습니까?`; + + if (window.confirm(message)) { + onDelete(category); + } + }; + + return ( +
+
+ +
+

+ {category.name} +

+

+ {category.todo_count}개 할일 +

+
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/categories/CategoryList.tsx b/frontend/src/components/categories/CategoryList.tsx new file mode 100644 index 0000000..ea1790f --- /dev/null +++ b/frontend/src/components/categories/CategoryList.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { FolderOpen, Loader2 } from "lucide-react"; +import { Category } from "@/types"; +import CategoryItem from "./CategoryItem"; + +interface CategoryListProps { + categories: Category[] | undefined; + isLoading: boolean; + onEdit: (category: Category) => void; + onDelete: (category: Category) => void; +} + +export default function CategoryList({ + categories, + isLoading, + onEdit, + onDelete, +}: CategoryListProps) { + if (isLoading) { + return ( +
+ + 카테고리를 불러오는 중... +
+ ); + } + + if (!categories || categories.length === 0) { + return ( +
+ +

카테고리가 없습니다

+

새 카테고리를 추가해보세요

+
+ ); + } + + return ( +
+ {categories.map((category) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/categories/ColorPicker.tsx b/frontend/src/components/categories/ColorPicker.tsx new file mode 100644 index 0000000..27a60c6 --- /dev/null +++ b/frontend/src/components/categories/ColorPicker.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { Check } from "lucide-react"; +import { COLOR_PRESETS, cn } from "@/lib/utils"; + +interface ColorPickerProps { + selectedColor: string; + onChange: (color: string) => void; +} + +export default function ColorPicker({ + selectedColor, + onChange, +}: ColorPickerProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+ {COLOR_PRESETS.map((color) => ( + + ))} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/components/common/AttachmentList.tsx b/frontend/src/components/common/AttachmentList.tsx new file mode 100644 index 0000000..00a31dd --- /dev/null +++ b/frontend/src/components/common/AttachmentList.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Download, Trash2, FileText, Image as ImageIcon, File } from "lucide-react"; +import { Attachment } from "@/types"; + +interface AttachmentListProps { + attachments: Attachment[]; + onDownload: (attachment: Attachment) => void; + onDelete?: (attachmentId: string) => void; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function FileIcon({ contentType }: { contentType: string }) { + if (contentType.startsWith("image/")) { + return ; + } + if (contentType === "application/pdf") { + return ; + } + return ; +} + +export default function AttachmentList({ + attachments, + onDownload, + onDelete, +}: AttachmentListProps) { + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map((attachment) => ( +
+
+ +
+ +
+

+ {attachment.filename} +

+

+ {formatFileSize(attachment.size)} +

+
+ +
+ + + {onDelete && ( + + )} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/common/DatePicker.tsx b/frontend/src/components/common/DatePicker.tsx new file mode 100644 index 0000000..15f42b1 --- /dev/null +++ b/frontend/src/components/common/DatePicker.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Calendar } from "lucide-react"; + +interface DatePickerProps { + value: string | null | undefined; + onChange: (date: string | null) => void; + label?: string; + clearLabel?: string; +} + +export default function DatePicker({ value, onChange, label, clearLabel = "날짜 해제" }: DatePickerProps) { + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + if (val) { + onChange(new Date(val + "T00:00:00Z").toISOString()); + } else { + onChange(null); + } + }; + + const displayValue = value + ? new Date(value).toISOString().split("T")[0] + : ""; + + return ( +
+ {label && ( + + )} +
+ + +
+ {value && ( + + )} +
+ ); +} diff --git a/frontend/src/components/common/FileUpload.tsx b/frontend/src/components/common/FileUpload.tsx new file mode 100644 index 0000000..8c3db0c --- /dev/null +++ b/frontend/src/components/common/FileUpload.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useRef } from "react"; +import { Upload, Loader2 } from "lucide-react"; + +interface FileUploadProps { + onFilesSelected: (files: File[]) => void; + maxFiles?: number; + maxSizeMB?: number; + isUploading?: boolean; + disabled?: boolean; +} + +export default function FileUpload({ + onFilesSelected, + maxFiles = 5, + maxSizeMB = 10, + isUploading = false, + disabled = false, +}: FileUploadProps) { + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + if (files.length > maxFiles) { + alert(`최대 ${maxFiles}개의 파일만 선택할 수 있습니다.`); + return; + } + + const maxBytes = maxSizeMB * 1024 * 1024; + const oversized = files.filter((f) => f.size > maxBytes); + if (oversized.length > 0) { + alert(`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다.`); + return; + } + + onFilesSelected(files); + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( +
+ + +

+ 최대 {maxFiles}개, 파일당 {maxSizeMB}MB +

+
+ ); +} diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx new file mode 100644 index 0000000..1b1cf5f --- /dev/null +++ b/frontend/src/components/common/Pagination.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export default function Pagination({ + currentPage, + totalPages, + onPageChange, +}: PaginationProps) { + if (totalPages <= 1) return null; + + const getPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + const maxVisible = 5; + + if (totalPages <= maxVisible + 2) { + for (let i = 1; i <= totalPages; i++) pages.push(i); + } else { + pages.push(1); + + if (currentPage > 3) { + pages.push("..."); + } + + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (currentPage < totalPages - 2) { + pages.push("..."); + } + + pages.push(totalPages); + } + + return pages; + }; + + return ( +
+ + + {getPageNumbers().map((page, idx) => + page === "..." ? ( + + ... + + ) : ( + + ) + )} + + +
+ ); +} diff --git a/frontend/src/components/common/PriorityBadge.tsx b/frontend/src/components/common/PriorityBadge.tsx new file mode 100644 index 0000000..704099e --- /dev/null +++ b/frontend/src/components/common/PriorityBadge.tsx @@ -0,0 +1,28 @@ +import { Priority } from "@/types"; +import { getPriorityConfig } from "@/lib/utils"; +import { cn } from "@/lib/utils"; + +interface PriorityBadgeProps { + priority: Priority; + className?: string; +} + +export default function PriorityBadge({ + priority, + className, +}: PriorityBadgeProps) { + const config = getPriorityConfig(priority); + + return ( + + + {config.label} + + ); +} diff --git a/frontend/src/components/common/TagBadge.tsx b/frontend/src/components/common/TagBadge.tsx new file mode 100644 index 0000000..c4c75f6 --- /dev/null +++ b/frontend/src/components/common/TagBadge.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface TagBadgeProps { + name: string; + onClick?: () => void; + onRemove?: () => void; + className?: string; +} + +export default function TagBadge({ + name, + onClick, + onRemove, + className, +}: TagBadgeProps) { + return ( + + + #{name} + + {onRemove && ( + + )} + + ); +} diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx new file mode 100644 index 0000000..02ea1c7 --- /dev/null +++ b/frontend/src/components/common/TagInput.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState, useRef, useCallback, KeyboardEvent } from "react"; +import TagBadge from "./TagBadge"; +import { useTagList } from "@/hooks/useTags"; + +interface TagInputProps { + tags: string[]; + onAdd: (tag: string) => void; + onRemove: (tag: string) => void; +} + +export default function TagInput({ tags, onAdd, onRemove }: TagInputProps) { + const [input, setInput] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + const { data: allTags } = useTagList(); + + const suggestions = allTags + ?.filter( + (t) => + t.name.toLowerCase().includes(input.toLowerCase()) && + !tags.includes(t.name) + ) + .slice(0, 5); + + const addTag = useCallback( + (tag: string) => { + const trimmed = tag.trim().toLowerCase(); + if (trimmed && !tags.includes(trimmed) && tags.length < 10) { + onAdd(trimmed); + setInput(""); + setShowSuggestions(false); + } + }, + [tags, onAdd] + ); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + if (input.trim()) { + addTag(input); + } + } else if (e.key === "Backspace" && !input && tags.length > 0) { + onRemove(tags[tags.length - 1]); + } + }; + + return ( +
+
+ {tags.map((tag) => ( + onRemove(tag)} /> + ))} + { + setInput(e.target.value); + setShowSuggestions(true); + }} + onKeyDown={handleKeyDown} + onFocus={() => setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} + placeholder={tags.length === 0 ? "태그 입력 (Enter로 추가)..." : ""} + className="flex-1 min-w-[120px] text-sm outline-none bg-transparent" + disabled={tags.length >= 10} + /> +
+ + {/* Suggestions dropdown */} + {showSuggestions && input && suggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} + + {tags.length >= 10 && ( +

최대 10개까지 추가 가능합니다.

+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/CategoryChart.tsx b/frontend/src/components/dashboard/CategoryChart.tsx new file mode 100644 index 0000000..27ecbfa --- /dev/null +++ b/frontend/src/components/dashboard/CategoryChart.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend, +} from "recharts"; +import { CategoryStat } from "@/types"; + +interface CategoryChartProps { + data: CategoryStat[] | undefined; + isLoading: boolean; +} + +export default function CategoryChart({ data, isLoading }: CategoryChartProps) { + if (isLoading) { + return ( +
+

+ 카테고리별 분포 +

+
+
+
+
+ ); + } + + if (!data || data.length === 0) { + return ( +
+

+ 카테고리별 분포 +

+
+ 데이터가 없습니다 +
+
+ ); + } + + const chartData = data.map((item) => ({ + name: item.name, + value: item.count, + color: item.color, + })); + + return ( +
+

+ 카테고리별 분포 +

+
+ + + + {chartData.map((entry, index) => ( + + ))} + + [ + `${value}개`, + name, + ]} + contentStyle={{ + borderRadius: "8px", + border: "1px solid #e5e7eb", + fontSize: "12px", + }} + /> + ( + {value} + )} + /> + + +
+
+ ); +} diff --git a/frontend/src/components/dashboard/PriorityChart.tsx b/frontend/src/components/dashboard/PriorityChart.tsx new file mode 100644 index 0000000..fb8f9e8 --- /dev/null +++ b/frontend/src/components/dashboard/PriorityChart.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import { PriorityStat } from "@/types"; +import { PRIORITY_CONFIG } from "@/lib/utils"; + +interface PriorityChartProps { + data: PriorityStat | undefined; + isLoading: boolean; +} + +export default function PriorityChart({ data, isLoading }: PriorityChartProps) { + if (isLoading) { + return ( +
+

+ 우선순위별 현황 +

+
+
+
+
+
+
+
+
+ ); + } + + if (!data) { + return ( +
+

+ 우선순위별 현황 +

+
+ 데이터가 없습니다 +
+
+ ); + } + + const chartData = [ + { + name: PRIORITY_CONFIG.high.label, + value: data.high, + color: PRIORITY_CONFIG.high.barColor, + }, + { + name: PRIORITY_CONFIG.medium.label, + value: data.medium, + color: PRIORITY_CONFIG.medium.barColor, + }, + { + name: PRIORITY_CONFIG.low.label, + value: data.low, + color: PRIORITY_CONFIG.low.barColor, + }, + ]; + + return ( +
+

+ 우선순위별 현황 +

+
+ + + + + [`${value}개`]} + contentStyle={{ + borderRadius: "8px", + border: "1px solid #e5e7eb", + fontSize: "12px", + }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + +
+
+ ); +} diff --git a/frontend/src/components/dashboard/StatsCards.tsx b/frontend/src/components/dashboard/StatsCards.tsx new file mode 100644 index 0000000..7cb541d --- /dev/null +++ b/frontend/src/components/dashboard/StatsCards.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + ListChecks, + CheckCircle2, + Circle, + TrendingUp, +} from "lucide-react"; +import { DashboardOverview } from "@/types"; + +interface StatsCardsProps { + stats: DashboardOverview | undefined; + isLoading: boolean; +} + +export default function StatsCards({ stats, isLoading }: StatsCardsProps) { + const cards = [ + { + label: "전체 할일", + value: stats?.total ?? 0, + icon: ListChecks, + color: "text-blue-600", + bgColor: "bg-blue-50", + }, + { + label: "완료", + value: stats?.completed ?? 0, + icon: CheckCircle2, + color: "text-green-600", + bgColor: "bg-green-50", + }, + { + label: "미완료", + value: stats?.incomplete ?? 0, + icon: Circle, + color: "text-orange-600", + bgColor: "bg-orange-50", + }, + { + label: "완료율", + value: `${(stats?.completion_rate ?? 0).toFixed(0)}%`, + icon: TrendingUp, + color: "text-purple-600", + bgColor: "bg-purple-50", + }, + ]; + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + if (!stats) { + return ( +
+ {cards.map((card) => ( +
+
+ + {card.label} + +
+ +
+
+

0

+
+ ))} +
+ ); + } + + return ( +
+ {cards.map((card) => ( +
+
+ + {card.label} + +
+ +
+
+

{card.value}

+
+ ))} +
+ ); +} diff --git a/frontend/src/components/dashboard/UpcomingDeadlines.tsx b/frontend/src/components/dashboard/UpcomingDeadlines.tsx new file mode 100644 index 0000000..463f51c --- /dev/null +++ b/frontend/src/components/dashboard/UpcomingDeadlines.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Clock, AlertTriangle } from "lucide-react"; +import { UpcomingDeadline } from "@/types"; +import { getDDayText, getPriorityConfig } from "@/lib/utils"; +import { cn } from "@/lib/utils"; + +interface UpcomingDeadlinesProps { + deadlines: UpcomingDeadline[] | undefined; + isLoading: boolean; +} + +export default function UpcomingDeadlines({ + deadlines, + isLoading, +}: UpcomingDeadlinesProps) { + const router = useRouter(); + + if (isLoading) { + return ( +
+

+ 마감 임박 할일 +

+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + if (!deadlines || deadlines.length === 0) { + return ( +
+

+ 마감 임박 할일 +

+
+ +

마감 임박 할일이 없습니다

+
+
+ ); + } + + return ( +
+

+ 마감 임박 할일 +

+
+ {deadlines.map((item, index) => { + const dday = getDDayText(item.due_date); + const priorityConfig = getPriorityConfig(item.priority); + + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..6392354 --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import { Menu, Bell } from "lucide-react"; +import { useUIStore } from "@/store/uiStore"; +import SearchBar from "@/components/search/SearchBar"; + +export default function Header() { + const { toggleSidebar } = useUIStore(); + + return ( +
+ {/* Left: Logo + Menu Toggle */} +
+ + +
+ T +
+ + todos2 + + +
+ + {/* Center: Search Bar */} +
+ +
+ + {/* Right: Notifications */} +
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000..0d06ecf --- /dev/null +++ b/frontend/src/components/layout/MainLayout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import Header from "./Header"; +import Sidebar from "./Sidebar"; + +export default function MainLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+ +
+ {children} +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..07f89e7 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { + LayoutDashboard, + ListTodo, + FolderOpen, + Tag, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useUIStore } from "@/store/uiStore"; +import { useCategoryList } from "@/hooks/useCategories"; +import { useTagList } from "@/hooks/useTags"; + +const navItems = [ + { href: "/", label: "대시보드", icon: LayoutDashboard }, + { href: "/todos", label: "할일 목록", icon: ListTodo }, + { href: "/categories", label: "카테고리 관리", icon: FolderOpen }, +]; + +export default function Sidebar() { + const pathname = usePathname(); + const router = useRouter(); + const { sidebarOpen, setSidebarOpen } = useUIStore(); + const { data: categories } = useCategoryList(); + const { data: tags } = useTagList(); + + const handleCategoryClick = (categoryId: string) => { + router.push(`/todos?category_id=${categoryId}`); + setSidebarOpen(false); + }; + + const handleTagClick = (tagName: string) => { + router.push(`/todos?tag=${encodeURIComponent(tagName)}`); + setSidebarOpen(false); + }; + + const handleNavClick = (href: string) => { + router.push(href); + setSidebarOpen(false); + }; + + return ( + <> + {/* Overlay for mobile */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + ); +} diff --git a/frontend/src/components/providers/QueryProvider.tsx b/frontend/src/components/providers/QueryProvider.tsx new file mode 100644 index 0000000..15a6e2e --- /dev/null +++ b/frontend/src/components/providers/QueryProvider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; + +export default function QueryProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return ( + {children} + ); +} diff --git a/frontend/src/components/search/SearchBar.tsx b/frontend/src/components/search/SearchBar.tsx new file mode 100644 index 0000000..694cdb4 --- /dev/null +++ b/frontend/src/components/search/SearchBar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState, useCallback, KeyboardEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Search, X } from "lucide-react"; +import { useUIStore } from "@/store/uiStore"; + +export default function SearchBar() { + const router = useRouter(); + const { searchQuery, setSearchQuery } = useUIStore(); + const [inputValue, setInputValue] = useState(searchQuery); + + const handleSearch = useCallback(() => { + const trimmed = inputValue.trim(); + if (trimmed) { + setSearchQuery(trimmed); + router.push(`/search?q=${encodeURIComponent(trimmed)}`); + } + }, [inputValue, setSearchQuery, router]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + const handleClear = () => { + setInputValue(""); + setSearchQuery(""); + }; + + return ( +
+ + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="할일 검색..." + className="w-full pl-10 pr-10 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {inputValue && ( + + )} +
+ ); +} diff --git a/frontend/src/components/search/SearchResults.tsx b/frontend/src/components/search/SearchResults.tsx new file mode 100644 index 0000000..de0e360 --- /dev/null +++ b/frontend/src/components/search/SearchResults.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Search, Loader2 } from "lucide-react"; +import { Todo } from "@/types"; +import { cn, getDueDateStatus, getDueDateColor, getDueDateLabel, getDDayText } from "@/lib/utils"; +import PriorityBadge from "@/components/common/PriorityBadge"; + +interface SearchResultsProps { + results: Todo[] | undefined; + query: string; + total: number; + isLoading: boolean; +} + +function highlightText(text: string, query: string): React.ReactNode { + if (!query.trim()) return text; + + const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, "gi")); + + return parts.map((part, i) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + ); +} + +export default function SearchResults({ + results, + query, + total, + isLoading, +}: SearchResultsProps) { + const router = useRouter(); + + if (isLoading) { + return ( +
+ + 검색 중... +
+ ); + } + + if (!results || results.length === 0) { + return ( +
+ +

검색 결과가 없습니다

+

다른 키워드로 검색해보세요

+
+ ); + } + + return ( +
+

+ "{query}" 검색 결과 ({total}건) +

+ +
+ {results.map((todo) => { + const dueDateStatus = getDueDateStatus(todo.due_date, todo.completed); + const dday = getDDayText(todo.due_date); + + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/todos/BatchActions.tsx b/frontend/src/components/todos/BatchActions.tsx new file mode 100644 index 0000000..767c050 --- /dev/null +++ b/frontend/src/components/todos/BatchActions.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; +import { CheckCircle2, Trash2, FolderOpen, X } from "lucide-react"; +import { useCategoryList } from "@/hooks/useCategories"; + +interface BatchActionsProps { + selectedCount: number; + onBatchComplete: () => void; + onBatchDelete: () => void; + onBatchMove: (categoryId: string | null) => void; + onClearSelection: () => void; +} + +export default function BatchActions({ + selectedCount, + onBatchComplete, + onBatchDelete, + onBatchMove, + onClearSelection, +}: BatchActionsProps) { + const { data: categories } = useCategoryList(); + const [showCategoryDropdown, setShowCategoryDropdown] = useState(false); + + if (selectedCount === 0) return null; + + const handleDelete = () => { + if ( + window.confirm(`${selectedCount}개의 할일을 삭제하시겠습니까?`) + ) { + onBatchDelete(); + } + }; + + return ( +
+ + {selectedCount}개 선택됨 + + +
+ + + {/* Category move dropdown */} +
+ + + {showCategoryDropdown && ( +
+ + {categories?.map((cat) => ( + + ))} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/todos/GanttChart.tsx b/frontend/src/components/todos/GanttChart.tsx new file mode 100644 index 0000000..fc8787d --- /dev/null +++ b/frontend/src/components/todos/GanttChart.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useMemo, useRef } from "react"; +import { Loader2 } from "lucide-react"; +import { useGanttData, GanttCategory, GanttTodo } from "@/hooks/useGantt"; +import { useUIStore } from "@/store/uiStore"; + +const DAY_WIDTH = 32; +const ROW_HEIGHT = 32; +const LABEL_WIDTH = 160; + +function formatDate(date: Date): string { + const m = date.getMonth() + 1; + const d = date.getDate(); + return `${m}/${d}`; +} + +function getMonthLabel(date: Date): string { + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +function daysBetween(a: Date, b: Date): number { + return Math.ceil((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)); +} + +function getPriorityColor(priority: string, baseColor: string): string { + if (priority === "high") return baseColor; + if (priority === "medium") return baseColor + "CC"; + return baseColor + "99"; +} + +interface GanttBarProps { + todo: GanttTodo; + minDate: Date; + color: string; +} + +function GanttBar({ todo, minDate, color }: GanttBarProps) { + const openTodoForm = useUIStore((s) => s.openTodoForm); + const offsetDays = daysBetween(minDate, todo.startDate); + const durationDays = Math.max(1, daysBetween(todo.startDate, todo.endDate)); + const left = offsetDays * DAY_WIDTH; + const width = durationDays * DAY_WIDTH; + const barColor = getPriorityColor(todo.priority, color); + + return ( +
openTodoForm("edit", todo.id)} + > +
+ + {todo.title} + + {todo.completed && ( +
+
+
+ )} +
+ {/* Tooltip */} +
+
{todo.title}
+
+ {formatDate(todo.startDate)} ~ {formatDate(todo.endDate)} ({durationDays}일) +
+ {todo.completed &&
완료
} +
+
+ ); +} + +interface GanttChartProps { + categoryId?: string; +} + +export default function GanttChart({ categoryId }: GanttChartProps) { + const { data, isLoading, isError } = useGanttData(categoryId); + const scrollRef = useRef(null); + + // 날짜 헤더 생성 + const { dateHeaders, monthHeaders } = useMemo(() => { + if (!data) return { dateHeaders: [], monthHeaders: [] }; + + const dateHeaders: { date: Date; label: string; isWeekend: boolean }[] = []; + const monthHeaders: { label: string; span: number; startIdx: number }[] = []; + + let currentMonth = ""; + let monthStartIdx = 0; + let monthSpan = 0; + + for (let i = 0; i <= data.totalDays; i++) { + const d = new Date(data.minDate); + d.setDate(d.getDate() + i); + const dayOfWeek = d.getDay(); + + dateHeaders.push({ + date: d, + label: String(d.getDate()), + isWeekend: dayOfWeek === 0 || dayOfWeek === 6, + }); + + const monthKey = getMonthLabel(d); + if (monthKey !== currentMonth) { + if (currentMonth) { + monthHeaders.push({ + label: currentMonth, + span: monthSpan, + startIdx: monthStartIdx, + }); + } + currentMonth = monthKey; + monthStartIdx = i; + monthSpan = 1; + } else { + monthSpan++; + } + } + if (currentMonth) { + monthHeaders.push({ + label: currentMonth, + span: monthSpan, + startIdx: monthStartIdx, + }); + } + + return { dateHeaders, monthHeaders }; + }, [data]); + + // 행 데이터: 카테고리 헤더 + Todo 행 + const rows = useMemo(() => { + if (!data) return []; + const result: { type: "category" | "todo"; category: GanttCategory; todo?: GanttTodo }[] = []; + data.categories.forEach((cat) => { + result.push({ type: "category", category: cat }); + cat.todos.forEach((todo) => { + result.push({ type: "todo", category: cat, todo }); + }); + }); + return result; + }, [data]); + + if (isLoading) { + return ( +
+ + 간트차트 로딩 중... +
+ ); + } + + if (isError) { + return ( +
+ 데이터를 불러오는 중 오류가 발생했습니다. +
+ ); + } + + if (!data || data.categories.length === 0) { + return ( +
+

표시할 일정이 없습니다

+

+ 할일에 시작일과 마감일을 설정하면 간트차트에 표시됩니다. +

+
+ ); + } + + const chartWidth = (data.totalDays + 1) * DAY_WIDTH; + + // 오늘 표시 위치 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayOffset = daysBetween(data.minDate, today); + const showTodayLine = + todayOffset >= 0 && todayOffset <= data.totalDays; + + return ( +
+
+ {/* 좌측 라벨 영역 */} +
+ {/* 월/일 헤더 높이 맞춤 */} +
+ 카테고리 / 할일 +
+ {/* 행 라벨 */} + {rows.map((row) => + row.type === "category" ? ( +
+ + {row.category.name} +
+ ) : ( +
+ {row.todo!.title} +
+ ) + )} +
+ + {/* 우측 차트 영역 (스크롤) */} +
+
+ {/* 헤더: 월 */} +
+ {monthHeaders.map((mh) => ( +
+ {mh.label} +
+ ))} +
+ {/* 헤더: 일 */} +
+ {dateHeaders.map((dh, i) => ( +
+ {dh.label} +
+ ))} +
+ {/* 차트 바디 */} +
+ {/* 그리드 라인 (주말 배경) */} +
+ {dateHeaders.map((dh, i) => ( +
+ ))} +
+ + {/* 오늘 선 */} + {showTodayLine && ( +
+ )} + + {/* 행 */} + {rows.map((row) => + row.type === "category" ? ( +
+ ) : ( +
+ +
+ ) + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/todos/TodoCard.tsx b/frontend/src/components/todos/TodoCard.tsx new file mode 100644 index 0000000..0cdc65c --- /dev/null +++ b/frontend/src/components/todos/TodoCard.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { Pencil, Trash2, Check, Paperclip } from "lucide-react"; +import { Todo } from "@/types"; +import { cn, getDueDateStatus, getDueDateLabel, getDueDateColor, getDDayText } from "@/lib/utils"; +import PriorityBadge from "@/components/common/PriorityBadge"; +import TagBadge from "@/components/common/TagBadge"; + +interface TodoCardProps { + todo: Todo; + isSelected: boolean; + onToggle: (id: string) => void; + onSelect: (id: string) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; + onTagClick?: (tag: string) => void; +} + +export default function TodoCard({ + todo, + isSelected, + onToggle, + onSelect, + onEdit, + onDelete, + onTagClick, +}: TodoCardProps) { + const dueDateStatus = getDueDateStatus(todo.due_date, todo.completed); + const dueDateLabel = getDueDateLabel(dueDateStatus); + const dueDateColor = getDueDateColor(dueDateStatus); + const dday = getDDayText(todo.due_date); + + return ( +
+ {/* Selection checkbox */} + onSelect(todo.id)} + className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer flex-shrink-0" + /> + + {/* Completion toggle */} + + + {/* Content */} +
onEdit(todo.id)} + > +
+

+ {todo.title} +

+ + {/* Category badge */} + {todo.category_name && ( + + + {todo.category_name} + + )} + + + + {/* Tags */} + {todo.tags.map((tag) => ( + onTagClick(tag) : undefined} + /> + ))} + + {/* Attachment indicator */} + {todo.attachments?.length > 0 && ( + + + {todo.attachments.length} + + )} +
+
+ + {/* Due date */} + {todo.due_date && ( +
+ {dueDateLabel && !todo.completed && ( + + {dueDateLabel} + + )} + + {dday} + +
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/todos/TodoFilter.tsx b/frontend/src/components/todos/TodoFilter.tsx new file mode 100644 index 0000000..7fb7f8e --- /dev/null +++ b/frontend/src/components/todos/TodoFilter.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { X } from "lucide-react"; +import { useUIStore } from "@/store/uiStore"; +import { Priority, SortField, SortOrder } from "@/types"; + +export default function TodoFilter() { + const { filters, setFilter, resetFilters } = useUIStore(); + + const hasActiveFilters = + filters.completed !== undefined || + filters.priority !== undefined || + filters.category_id !== undefined || + filters.tag !== undefined; + + return ( +
+ {/* Status filter */} +
+ +
+ + {/* Priority filter */} +
+ +
+ + {/* Sort */} +
+ +
+ + {/* Active tag filter */} + {filters.tag && ( + + #{filters.tag} + + + )} + + {/* Reset button */} + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/frontend/src/components/todos/TodoList.tsx b/frontend/src/components/todos/TodoList.tsx new file mode 100644 index 0000000..1e60adf --- /dev/null +++ b/frontend/src/components/todos/TodoList.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { ListX, Loader2 } from "lucide-react"; +import { Todo } from "@/types"; +import TodoCard from "./TodoCard"; + +interface TodoListProps { + todos: Todo[] | undefined; + selectedIds: string[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + onToggle: (id: string) => void; + onSelect: (id: string) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; + onTagClick?: (tag: string) => void; +} + +export default function TodoList({ + todos, + selectedIds, + isLoading, + isError, + error, + onToggle, + onSelect, + onEdit, + onDelete, + onTagClick, +}: TodoListProps) { + if (isLoading) { + return ( +
+ + 할일 목록을 불러오는 중... +
+ ); + } + + if (isError) { + return ( +
+

오류가 발생했습니다

+

+ {error?.message || "할일 목록을 불러올 수 없습니다"} +

+
+ ); + } + + if (!todos || todos.length === 0) { + return ( +
+ +

할일이 없습니다

+

새 할일을 추가해보세요

+
+ ); + } + + return ( +
+ {todos.map((todo) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/todos/TodoModal.tsx b/frontend/src/components/todos/TodoModal.tsx new file mode 100644 index 0000000..46288a9 --- /dev/null +++ b/frontend/src/components/todos/TodoModal.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { X, Trash2, Loader2 } from "lucide-react"; +import { Todo, TodoCreate, TodoUpdate, Priority, Attachment } from "@/types"; +import { useCategoryList } from "@/hooks/useCategories"; +import { + useCreateTodo, + useUpdateTodo, + useDeleteTodo, + useUploadAttachments, + useDeleteAttachment, +} from "@/hooks/useTodos"; +import { apiClient } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import TagInput from "@/components/common/TagInput"; +import DatePicker from "@/components/common/DatePicker"; +import FileUpload from "@/components/common/FileUpload"; +import AttachmentList from "@/components/common/AttachmentList"; + +interface TodoModalProps { + mode: "create" | "edit"; + todo?: Todo | null; + isOpen: boolean; + onClose: () => void; +} + +type TabType = "basic" | "extra"; + +export default function TodoModal({ + mode, + todo, + isOpen, + onClose, +}: TodoModalProps) { + const { data: categories } = useCategoryList(); + const createTodo = useCreateTodo(); + const updateTodo = useUpdateTodo(); + const deleteTodo = useDeleteTodo(); + const uploadAttachments = useUploadAttachments(); + const deleteAttachment = useDeleteAttachment(); + + const [activeTab, setActiveTab] = useState("basic"); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [categoryId, setCategoryId] = useState(null); + const [priority, setPriority] = useState("medium"); + const [startDate, setStartDate] = useState(null); + const [dueDate, setDueDate] = useState(null); + const [tags, setTags] = useState([]); + const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + useEffect(() => { + if (mode === "edit" && todo) { + setTitle(todo.title); + setContent(todo.content || ""); + setCategoryId(todo.category_id || null); + setPriority(todo.priority); + setStartDate(todo.start_date || null); + setDueDate(todo.due_date || null); + setTags(todo.tags || []); + } else if (mode === "create") { + setTitle(""); + setContent(""); + setCategoryId(null); + setPriority("medium"); + setStartDate(null); + setDueDate(null); + setTags([]); + } + setActiveTab("basic"); + setError(""); + setSuccessMessage(""); + }, [mode, todo, isOpen]); + + const handleSave = async () => { + setError(""); + setSuccessMessage(""); + + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + setError("제목을 입력해주세요"); + return; + } + if (trimmedTitle.length > 200) { + setError("제목은 200자 이하로 입력해주세요"); + return; + } + + try { + if (mode === "create") { + const data: TodoCreate = { + title: trimmedTitle, + content: content.trim() || undefined, + category_id: categoryId, + priority, + start_date: startDate, + due_date: dueDate, + tags, + }; + await createTodo.mutateAsync(data); + onClose(); + } else if (todo) { + const data: TodoUpdate = { + title: trimmedTitle, + content: content.trim() || null, + category_id: categoryId, + priority, + start_date: startDate, + due_date: dueDate, + tags, + }; + await updateTodo.mutateAsync({ id: todo.id, data }); + setSuccessMessage("저장되었습니다"); + setTimeout(() => setSuccessMessage(""), 3000); + } + } catch (err: unknown) { + const apiErr = err as { detail?: string }; + setError(apiErr.detail || "저장에 실패했습니다"); + } + }; + + const handleDelete = async () => { + if (!todo) return; + if (!window.confirm("이 할일을 삭제하시겠습니까?")) return; + + try { + await deleteTodo.mutateAsync(todo.id); + onClose(); + } catch (err: unknown) { + const apiErr = err as { detail?: string }; + setError(apiErr.detail || "삭제에 실패했습니다"); + } + }; + + const handleFileUpload = async (files: File[]) => { + if (!todo) return; + setError(""); + try { + await uploadAttachments.mutateAsync({ todoId: todo.id, files }); + setSuccessMessage("파일이 업로드되었습니다"); + setTimeout(() => setSuccessMessage(""), 3000); + } catch (err: unknown) { + const apiErr = err as { detail?: string }; + setError(apiErr.detail || "파일 업로드에 실패했습니다"); + } + }; + + const handleFileDelete = async (attachmentId: string) => { + if (!todo) return; + if (!window.confirm("이 파일을 삭제하시겠습니까?")) return; + setError(""); + try { + await deleteAttachment.mutateAsync({ todoId: todo.id, attachmentId }); + setSuccessMessage("파일이 삭제되었습니다"); + setTimeout(() => setSuccessMessage(""), 3000); + } catch (err: unknown) { + const apiErr = err as { detail?: string }; + setError(apiErr.detail || "파일 삭제에 실패했습니다"); + } + }; + + const handleFileDownload = (attachment: Attachment) => { + if (!todo) return; + apiClient.downloadFile( + `/api/todos/${todo.id}/attachments/${attachment.id}/download`, + attachment.filename + ); + }; + + const isSubmitting = + createTodo.isPending || updateTodo.isPending || deleteTodo.isPending; + + if (!isOpen) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

+ {mode === "create" ? "새 할일 추가" : "할일 수정"} +

+ +
+ + {/* Scrollable Content */} +
+ {/* Messages */} + {error && ( +
+ {error} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Title (always visible, above tabs) */} +
+ + setTitle(e.target.value)} + placeholder="할일 제목을 입력하세요" + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + maxLength={200} + autoFocus + /> +
+ + {/* Tab buttons */} +
+ + +
+ + {/* Tab Content - both tabs rendered, inactive hidden via grid overlap */} +
+
+ {/* Content */} +
+ +