diff --git a/.agents/skills/api-integration-workflow/SKILL.md b/.agents/skills/api-integration-workflow/SKILL.md new file mode 100644 index 0000000..4e96897 --- /dev/null +++ b/.agents/skills/api-integration-workflow/SKILL.md @@ -0,0 +1,53 @@ +--- +name: api-integration-workflow +description: Use this when adding API helpers, request/response types, TanStack Query hooks, query keys, or cache behavior. +--- + +# API Integration Workflow + +## Purpose + +Implement predictable API boundaries and React Query usage for Roominus Admin. + +## Read First + +- `AGENTS.md` +- `.agents/skills/project-conventions-workflow/SKILL.md` for file naming and commit conventions when relevant +- Existing API/client/query patterns in `src/features` and `src/shared` +- Endpoint docs or backend contract supplied by the user + +## Required Inputs + +- HTTP method and path +- Request params, search params, or body +- Response shape +- Expected error shape, if known +- Usage site or page + +If the API contract is unclear and cannot be inferred from local code, ask before inventing fields. + +## Rules + +- Keep request/response types close to the API boundary. +- Include every response-changing input in the query key. +- Prefer domain-local API helpers first. Promote to `src/shared` only when multiple real users exist. +- Do not add optimistic updates, retries, or broad invalidation unless the UX needs them. +- Surface errors explicitly enough for the page to render useful states. +- Keep mapping/normalization near the boundary when it protects the UI from backend shape changes. + +## Implementation Flow + +1. Find existing `axios` and `@tanstack/react-query` usage. +2. Add or reuse a typed API helper. +3. Add query keys before hooks when caching is involved. +4. Add query or mutation hooks with narrow inputs and readable return values. +5. Wire the hook into the page or component without mixing transport details into UI. +6. Verify lint/build according to change risk. + +## Done Criteria + +- Types describe the API boundary. +- Query keys include response-changing inputs. +- Cache invalidation or update behavior is intentional. +- Errors are not silently swallowed. +- Verification is run or the skipped check is explained. diff --git a/.agents/skills/api-integration-workflow/agents/openai.yaml b/.agents/skills/api-integration-workflow/agents/openai.yaml new file mode 100644 index 0000000..b0b5dc5 --- /dev/null +++ b/.agents/skills/api-integration-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'API Integration Workflow' + short_description: 'Add API helpers and React Query hooks.' + default_prompt: '$api-integration-workflow로 API 연동과 query key/cache 동작까지 구현해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/app-structure-evolution-workflow/SKILL.md b/.agents/skills/app-structure-evolution-workflow/SKILL.md new file mode 100644 index 0000000..bc6bd0e --- /dev/null +++ b/.agents/skills/app-structure-evolution-workflow/SKILL.md @@ -0,0 +1,111 @@ +--- +name: app-structure-evolution-workflow +description: Use this when deciding whether Roominus Admin should evolve from route-local pages into a domain-oriented Next.js App Router structure, especially when referencing the DONGCHIMI client folder structure, adding multiple related admin pages, creating route groups, or moving code between src/app, src/features, src/domains, and src/shared. +--- + +# App Structure Evolution Workflow + +## Purpose + +Guide structural decisions for Roominus Admin as it grows. Use DONGCHIMI client as a reference pattern, but adapt it conservatively to this repo's current size, stack, and admin product needs. + +## Read First + +- `AGENTS.md` +- Nearby files in `src/app`, `src/features`, `src/shared`, and `src/assets` +- When the user explicitly provides a reference project path, inspect only the relevant route, domain or feature, and shared folders from that path. + +## Current Baseline + +- Keep `src/app` as the routing layer. +- Keep small route-only UI close to the route while the app is still shallow. +- Use `src/shared` only for code reused by more than one page or clearly app-wide primitives. +- Use `src/features` for domain-specific code only when a feature has enough substance to justify the folder. +- Do not introduce Vanilla Extract, a monorepo package, generators, or DONGCHIMI-only infrastructure unless the user explicitly asks and the repo has the need. + +## Reference Pattern From DONGCHIMI + +DONGCHIMI client uses this dependency direction: + +```text +app -> domains -> shared +``` + +Its main idea is: + +- `src/app`: Next route entries, route groups, layouts, metadata, providers, route handlers. +- `src/domains/{domain}`: real page composition, domain APIs, hooks, models, query keys. +- `src/shared`: app-wide API clients, reusable UI, auth helpers, constants, query setup, hooks, utilities. + +Treat this as a growth target, not an immediate migration plan. + +Use the current repository's `src/app`, `src/features`, and `src/shared` as the default inputs for structure decisions. Compare with DONGCHIMI or any other reference project only when the user provides that reference path for the current task. + +## When To Stay Route-Local + +Keep code inside `src/app/{route}` or a nearby route-local component when: + +- The page is one screen with little logic. +- Components are not reused elsewhere. +- There is no domain API, query key, mutation, or shared model yet. +- Moving files would create ceremony without reducing complexity. + +Example: + +```text +src/app/login/page.tsx +``` + +## When To Introduce A Domain Folder + +Introduce a domain folder when at least two of these are true: + +- A route has several sections, components, hooks, or utilities. +- Multiple pages share one business domain. +- API helpers, query hooks, request/response models, or query keys appear. +- Page logic is becoming hard to scan inside `src/app`. +- A route group or admin shell separates auth pages from authenticated pages. + +Prefer this shape: + +```text +src/app/(auth)/login/page.tsx +src/features/auth/login/LoginPage.tsx +src/features/auth/login/components/ +src/features/auth/login/sections/ +``` + +If this repo later standardizes on `src/domains`, update `AGENTS.md` and this skill first, then migrate intentionally. + +## App Router Rules + +- Keep route files thin: parse `params` and `searchParams`, connect layouts, then render page composition. +- Keep pages and layouts as Server Components by default. +- Put `'use client'` only in the smallest component that needs state, events, browser APIs, effects, providers, or TanStack Query hooks. +- Use route groups only when they clarify layout or access boundaries, such as `(auth)` and `(admin)`. +- Put route constants in `src/shared/constants/routes.ts` once paths are referenced in multiple places. + +## API And State Placement + +- Keep request/response types near the API boundary. +- Put shared API helpers under `src/shared/api` only when more than one feature needs them. +- Put feature-specific API helpers and hooks under `src/features/{domain}` when they are not broadly reusable. +- Include response-changing inputs in React Query keys. +- Keep transport and mutation logic out of presentational components. + +## Migration Flow + +1. Identify the route or feature that is becoming large. +2. List the code that is route-only, feature/domain-level, and shared. +3. Move only the code whose new owner is clear. +4. Keep the route entry thin and Server Component compatible. +5. Update imports without renaming public routes unless requested. +6. Run the lightest useful verification: use `git diff --check` for docs-only or agent-only changes, `pnpm lint` for applicable code changes, and `pnpm build` for route, layout, or boundary changes. + +## Done Criteria + +- `src/app` remains focused on routing and Next conventions. +- New folders have a clear owner and are not speculative. +- Shared code is actually shared or obviously app-wide. +- Server/Client boundaries are still minimal. +- The result is easier to navigate than the previous structure. diff --git a/.agents/skills/app-structure-evolution-workflow/agents/openai.yaml b/.agents/skills/app-structure-evolution-workflow/agents/openai.yaml new file mode 100644 index 0000000..4305e59 --- /dev/null +++ b/.agents/skills/app-structure-evolution-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'App Structure Evolution Workflow' + short_description: 'Evolve Roominus Admin structure using DONGCHIMI as a reference.' + default_prompt: '$app-structure-evolution-workflow로 Roominus Admin의 Next.js 폴더 구조를 점검하고 성장 방향을 제안해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/form-flow-workflow/SKILL.md b/.agents/skills/form-flow-workflow/SKILL.md new file mode 100644 index 0000000..6c45d50 --- /dev/null +++ b/.agents/skills/form-flow-workflow/SKILL.md @@ -0,0 +1,57 @@ +--- +name: form-flow-workflow +description: Use this when implementing form validation, submit flows, mutations, and loading/error/success states. +--- + +# Form Flow Workflow + +## Purpose + +Implement predictable form behavior for Roominus Admin screens, including validation, submit state, server errors, and success handling. + +Use this with `api-integration-workflow` when a form submits to an API or uses a React Query mutation. + +## Read First + +- `AGENTS.md` +- `.agents/skills/page-feature-workflow/SKILL.md` for route ownership +- `.agents/skills/api-integration-workflow/SKILL.md` when submit calls an API +- Existing forms or input patterns in `src/app`, `src/features`, and `src/shared` + +## Required Inputs + +- Fields and input types +- Validation rules and user-facing error messages +- Submit behavior +- Loading, disabled, failure, and success behavior +- API request and error shape when server-backed + +If validation or submit behavior is unclear and cannot be inferred from nearby code, ask before inventing business rules. + +## Rules + +- Keep field state, validation, submit orchestration, and transport concerns separated enough to read. +- Do not hide server errors silently. +- Disable submit only for intentional conditions such as invalid input, unchanged state, or in-flight submission. +- Keep validation messages close to the relevant field when possible. +- Ensure labels, focus-visible states, keyboard submission, and error association are usable. +- Keep API payload mapping near the API boundary when it protects UI code. + +## Implementation Flow + +1. Decide whether the form belongs in the page, a route-local component, a feature component, or shared UI. +2. Map each field to its source of truth, validation rule, and displayed error. +3. Separate client validation from server error handling. +4. Add mutation or submit logic with explicit loading and failure states. +5. Handle success intentionally: refetch, invalidate, navigate, close dialog, or show feedback. +6. Check keyboard flow, disabled behavior, and layout stability. +7. Run targeted verification. + +## Done Criteria + +- Validation matches the requested behavior. +- Submit state and disabled state are intentional. +- Server errors are visible or otherwise handled deliberately. +- Loading, failure, and success states are consistent. +- Accessibility basics are covered for labels, focus, and error text. +- Verification is run or the skipped check is explained. diff --git a/.agents/skills/form-flow-workflow/agents/openai.yaml b/.agents/skills/form-flow-workflow/agents/openai.yaml new file mode 100644 index 0000000..44779ee --- /dev/null +++ b/.agents/skills/form-flow-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Form Flow Workflow' + short_description: 'Implement validation and submit state for forms.' + default_prompt: '$form-flow-workflow로 Roominus Admin 폼 흐름을 구현해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/frontend-fundamentals-review/SKILL.md b/.agents/skills/frontend-fundamentals-review/SKILL.md new file mode 100644 index 0000000..e6a7d40 --- /dev/null +++ b/.agents/skills/frontend-fundamentals-review/SKILL.md @@ -0,0 +1,65 @@ +--- +name: frontend-fundamentals-review +description: Use this when reviewing frontend diffs for readability, predictability, cohesion, coupling, and logic composition. +--- + +# Frontend Fundamentals Review + +## Purpose + +Review frontend changes for maintainability risks beyond formatter, lint, and build results. + +Use this for non-trivial React, page, component, hook, or API wiring changes. Skip it for docs-only or formatting-only edits. + +## Read First + +- `AGENTS.md` +- The current diff or requested files +- Nearby components, hooks, API helpers, and usage sites +- Relevant workflow skills for the changed area, such as `page-feature-workflow`, `shared-component-workflow`, `form-flow-workflow`, or `api-integration-workflow` + +## Review Criteria + +- Readability: names, control flow, component shape, and JSX structure are easy to scan. +- Predictability: props, state, effects, keys, memoization, and return values behave as callers expect. +- Cohesion: responsibilities stay close to the code that owns them. +- Coupling: route, copy, API, analytics, and styling details do not leak into generic code. +- Logic composition: extracted helpers or hooks have a real responsibility and stable usage sites. + +## Common Checks + +- Avoid render-time side effects. +- Avoid copying props into state without a clear synchronization rule. +- Avoid conditional hook calls, unstable list keys, and unnecessary `useMemo` or `useCallback`. +- Keep presentational components free of transport details. +- Keep route-only behavior out of shared components. +- Do not extract a hook only because a component is long. +- Prefer pure helpers for mapping, formatting, grouping, and validation that do not need React state. + +## Output Shape + +When reviewing, lead with findings: + +```markdown +Findings: + +- [Severity] `path/to/file.tsx:line` - Issue. + Fix: Suggested change. + +Open questions: + +- Question, if any. + +Residual risk: + +- Anything not checked. +``` + +If no issues are found, say that clearly and mention any remaining verification gap. + +## Done Criteria + +- Findings are tied to files and lines when possible. +- Suggestions are actionable and scoped. +- Taste-only preferences are separated from correctness or maintainability risks. +- Lint/build results are not treated as a substitute for review judgment. diff --git a/.agents/skills/frontend-fundamentals-review/agents/openai.yaml b/.agents/skills/frontend-fundamentals-review/agents/openai.yaml new file mode 100644 index 0000000..8f2b1a0 --- /dev/null +++ b/.agents/skills/frontend-fundamentals-review/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Frontend Fundamentals Review' + short_description: 'Review frontend diffs for maintainability risks.' + default_prompt: '$frontend-fundamentals-review로 현재 프론트엔드 변경사항을 리뷰해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/frontend-quality-verification/SKILL.md b/.agents/skills/frontend-quality-verification/SKILL.md new file mode 100644 index 0000000..4663ebb --- /dev/null +++ b/.agents/skills/frontend-quality-verification/SKILL.md @@ -0,0 +1,42 @@ +--- +name: frontend-quality-verification +description: Use this after Roominus Admin frontend changes to choose and run the smallest useful verification commands. +--- + +# Frontend Quality Verification + +## Purpose + +Pick checks that match the actual change. Keep verification light, but do enough to prove the work. + +## Read First + +- `package.json` +- Changed files from `git status --short` or `git diff --name-only` + +## Verification Ladder + +| Change Type | Checks | +| ---------------------------------------------------------------------- | ------------------- | +| Docs or agent files only | `git diff --check` | +| Formatting-sensitive docs or code | `pnpm format:check` | +| TypeScript, React, or shared UI changes | `pnpm lint` | +| Next route, config, build, or Server/Client Component boundary changes | `pnpm build` | + +Use `npm run ...` only if pnpm is not available in the environment. + +## Rules + +- Do not claim a check passed unless it was run successfully. +- If a check fails, separate failures caused by the current change from pre-existing failures when possible. +- If a check cannot run because dependencies or environment are missing, report that plainly. +- For UI changes, mention any manual browser check that still matters. + +## Output Shape + +```markdown +Verification: + +- `command`: pass/fail/not run +- Notes: +``` diff --git a/.agents/skills/frontend-quality-verification/agents/openai.yaml b/.agents/skills/frontend-quality-verification/agents/openai.yaml new file mode 100644 index 0000000..4e7e9de --- /dev/null +++ b/.agents/skills/frontend-quality-verification/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Frontend Quality Verification' + short_description: 'Choose and run the right frontend checks.' + default_prompt: '$frontend-quality-verification으로 이번 변경에 맞는 검증을 해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/frontend-task-orchestrator/SKILL.md b/.agents/skills/frontend-task-orchestrator/SKILL.md new file mode 100644 index 0000000..e854b6b --- /dev/null +++ b/.agents/skills/frontend-task-orchestrator/SKILL.md @@ -0,0 +1,58 @@ +--- +name: frontend-task-orchestrator +description: Use this to classify Roominus Admin frontend work, pick the smallest relevant skill, and decide what context and verification are needed. +--- + +# Frontend Task Orchestrator + +## Purpose + +Use this before non-trivial frontend work. Decide the task type, gather only the context needed, and route the work to the right lightweight workflow. + +## Read First + +- `AGENTS.md` +- Relevant files found with `rg` or `rg --files` +- For Next-specific changes, the closest matching file under `node_modules/next/dist/docs/01-app/` + +## Task Routing + +| Work Type | Use | +| --------------------------------------------------------------------- | --------------------------------- | +| Naming, assets, styling units, branch names, commit messages | `project-conventions-workflow` | +| App Router/domain folder structure evolution | `app-structure-evolution-workflow` | +| New or changed App Router page, layout, route-local UI | `page-feature-workflow` | +| Next Server/Client Component boundary decision | `server-client-boundary-workflow` | +| Reusable UI component under `src/shared/components` | `shared-component-workflow` | +| Form validation, submit, disabled/loading/error/success state | `form-flow-workflow` | +| API helper, request/response type, query key, query/mutation hook | `api-integration-workflow` | +| Frontend code quality review for a non-trivial diff | `frontend-fundamentals-review` | +| GitHub issue drafting, issue refinement, issue breakdown | `issue-workflow` | +| Pull request summary, checklist, issue link, review-ready description | `pr-prep-workflow` | +| Verification after docs or code changes | `frontend-quality-verification` | + +Do not invoke heavier workflows for Jira, Turbo, monorepos, design-system packages, PR monitoring, or browser PR review unless they are explicitly introduced to this repo. + +## Checklist + +1. Identify the user-visible goal and success criteria. +2. Confirm the affected route, feature, shared component, or API surface. +3. Read nearby existing code before choosing structure. +4. Apply `app-structure-evolution-workflow` before adopting DONGCHIMI-style route groups or domain folders. +5. Apply `project-conventions-workflow` when naming files/components/assets, suggesting branches, or drafting commits. +6. Keep route-local code local until reuse is real. +7. Choose verification before finishing. + +## Output Shape + +When a plan is useful, keep it short: + +```markdown +## Frontend Work Plan + +- Task type: +- Target files: +- Skill: +- Missing context: +- Verification: +``` diff --git a/.agents/skills/frontend-task-orchestrator/agents/openai.yaml b/.agents/skills/frontend-task-orchestrator/agents/openai.yaml new file mode 100644 index 0000000..7089ede --- /dev/null +++ b/.agents/skills/frontend-task-orchestrator/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Frontend Task Orchestrator' + short_description: 'Classify frontend work and choose the light workflow.' + default_prompt: '$frontend-task-orchestrator로 이 프론트엔드 작업의 범위와 진행 순서를 정리해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/issue-workflow/SKILL.md b/.agents/skills/issue-workflow/SKILL.md new file mode 100644 index 0000000..bad83bb --- /dev/null +++ b/.agents/skills/issue-workflow/SKILL.md @@ -0,0 +1,73 @@ +--- +name: issue-workflow +description: Use this when drafting, refining, splitting, or reviewing GitHub issues for Roominus Admin, including bug reports, feature requests, custom tasks, labels, title prefixes, acceptance criteria, TODO lists, reproduction steps, and references. +--- + +# Issue Workflow + +## Purpose + +Create concise GitHub issues that match this repo's templates and are ready to implement. + +## Read First + +- `AGENTS.md` +- Matching template under `.github/ISSUE_TEMPLATE/` +- `.github/labeler.yml` when choosing a title prefix +- Nearby source files only when issue scope depends on current implementation + +## Template Selection + +| Issue Type | Template | Title Prefix | +| -------------------- | ------------------------------------------- | ------------ | +| New feature | `.github/ISSUE_TEMPLATE/feature_request.md` | `[FEAT]` | +| Bug fix | `.github/ISSUE_TEMPLATE/bug_report.md` | `[FIX]` | +| API work | `.github/ISSUE_TEMPLATE/custom.md` | `[API]` | +| Refactor | `.github/ISSUE_TEMPLATE/custom.md` | `[REFACTOR]` | +| Docs | `.github/ISSUE_TEMPLATE/custom.md` | `[DOCS]` | +| Style/UI-only polish | `.github/ISSUE_TEMPLATE/custom.md` | `[STYLE]` | +| Test work | `.github/ISSUE_TEMPLATE/custom.md` | `[TEST]` | +| Config/setup | `.github/ISSUE_TEMPLATE/custom.md` | `[SETTING]` | + +Use another labeler prefix from `.github/labeler.yml` only when it clearly fits better. + +## Writing Rules + +- Treat the selected repository issue template as the source of truth for the final body structure. +- Preserve the template's headings, heading levels, order, and checklist structure. +- Do not replace template headings with generic sections such as `Summary`, `Details`, or `Acceptance Criteria`. +- Remove instructional blockquotes from the completed copy-ready draft. +- State the problem or goal in observable product terms. +- Keep TODO items implementation-sized and checkable. +- Include acceptance conditions when the requested behavior could be ambiguous. +- When the selected template has no acceptance-criteria section, express acceptance conditions as checkable items inside an existing TODO or detail section. +- For bugs, include current behavior, expected behavior, and reproduction steps using the bug-report template's existing sections. +- For UI work, mention target route, state, and viewport if known. +- For API work, mention endpoint, method, request inputs, response shape, and error behavior if known. +- Do not invent screenshots, links, owners, labels, assignees, or timelines. +- When a template contains an estimated-duration section and no duration was provided, leave it blank or mark it as `미정`. +- Add no new section unless the user explicitly requests it or the selected template cannot express required information. +- Ask only when missing information would make the issue misleading. + +## Output Shape + +When the user asks for issue text: + +1. Select the matching template. +2. Keep the selected template's existing headings, heading levels, order, and checklist structure. +3. Fill the existing sections without replacing them with a generic issue format. +4. Remove instructional comments and blockquotes from the final copy-ready body. +5. Add no new section unless the user explicitly requests it or the template cannot express required information. + +Provide: + +1. Selected template +2. Issue title +3. Copy-ready Markdown body using the exact selected template structure + +## Done Criteria + +- Title prefix matches intended label. +- The issue can be implemented without rereading the conversation. +- TODO and acceptance criteria are testable. +- Unknowns are clearly marked instead of guessed. diff --git a/.agents/skills/issue-workflow/agents/openai.yaml b/.agents/skills/issue-workflow/agents/openai.yaml new file mode 100644 index 0000000..51b4c42 --- /dev/null +++ b/.agents/skills/issue-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Issue Workflow' + short_description: 'Draft GitHub issues with repo templates and labels.' + default_prompt: '$issue-workflow Draft a Roominus Admin GitHub issue using the current templates and label conventions.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/page-feature-workflow/SKILL.md b/.agents/skills/page-feature-workflow/SKILL.md new file mode 100644 index 0000000..9a395c7 --- /dev/null +++ b/.agents/skills/page-feature-workflow/SKILL.md @@ -0,0 +1,58 @@ +--- +name: page-feature-workflow +description: Use this when adding or changing Roominus Admin pages, layouts, route-local components, or App Router behavior. +--- + +# Page Feature Workflow + +## Purpose + +Implement page, layout, and route-local UI changes in this Next.js App Router project. + +## Read First + +- `AGENTS.md` +- `.agents/skills/project-conventions-workflow/SKILL.md` for naming, assets, and styling units +- `.agents/skills/server-client-boundary-workflow/SKILL.md` when Server/Client Component boundaries matter +- Nearby files in `src/app`, `src/features`, and `src/shared` +- For Next route behavior: + - `node_modules/next/dist/docs/01-app/01-getting-started/02-project-structure.md` + - `node_modules/next/dist/docs/01-app/01-getting-started/03-layouts-and-pages.md` + - `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/page.md` + - `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/layout.md` + - `node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md` + +## Rules + +- `src/app` owns routes and route shell files. +- Keep required Next file names as `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, and `route.ts`. +- Use PascalCase for extracted page/component modules, such as `LoginPage.tsx` or `UserTable.tsx`. +- Keep route-only UI close to the route or feature. Move code to `src/shared` only when reuse is proven. +- Pages and layouts are Server Components by default. +- Add `'use client'` only where state, event handlers, effects, browser APIs, or client hooks are required. +- Keep `'use client'` at the smallest practical interactive component boundary. +- In App Router pages, `params` and `searchParams` are promises. Use `async/await` or React `use`. +- Preserve Korean UX text where the surrounding UI is Korean. +- Use lucide icons for icon buttons when available. +- Use camelCase for local asset filenames, SVG for icons, and PNG for raster images. +- Prefer `em` and `%` for scalable sizing, with `px` allowed for border width, border radius, and small fixed formatting details. +- Use Tailwind CSS 4 utilities and existing shadcn/Radix/cva patterns; do not introduce Vanilla Extract or route-specific global CSS. +- Keep admin UI dense, clear, and task-focused. + +## Implementation Flow + +1. Find the closest existing page or layout pattern. +2. Decide route ownership and whether the change belongs in `src/app`, `src/features`, or `src/shared`. +3. Decide Server/Client Component boundaries before adding hooks, handlers, providers, or browser APIs. +4. Model loading, empty, error, disabled, and success states when the workflow needs them. +5. Keep API calls and query hooks out of presentational components. +6. Check responsive behavior for common admin widths. +7. Run targeted verification. + +## Done Criteria + +- Route behavior matches the requested URL and navigation. +- Server/Client Component boundaries are intentional. +- Route params and search params follow Next 16 conventions. +- UI state is readable and does not hide failures. +- Verification is run or the skipped check is explained. diff --git a/.agents/skills/page-feature-workflow/agents/openai.yaml b/.agents/skills/page-feature-workflow/agents/openai.yaml new file mode 100644 index 0000000..63db5d2 --- /dev/null +++ b/.agents/skills/page-feature-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Page Feature Workflow' + short_description: 'Add or change Next App Router pages.' + default_prompt: '$page-feature-workflow로 Roominus Admin 페이지 또는 라우트 UI를 구현해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/pr-prep-workflow/SKILL.md b/.agents/skills/pr-prep-workflow/SKILL.md new file mode 100644 index 0000000..3f06651 --- /dev/null +++ b/.agents/skills/pr-prep-workflow/SKILL.md @@ -0,0 +1,98 @@ +--- +name: pr-prep-workflow +description: Use this when preparing, drafting, updating, or reviewing pull request descriptions for Roominus Admin, including issue links, PR titles, summaries, screenshots, test checklists, changed files, verification results, and review readiness. +--- + +# PR Prep Workflow + +## Purpose + +Prepare PR text that follows this repo's template and accurately reflects the local diff. + +## Read First + +- `AGENTS.md` +- `.agents/skills/project-conventions-workflow/SKILL.md` as the single source of truth for branch and commit conventions +- `.github/pull_request_template.md` +- `.github/labeler.yml` when choosing a title prefix +- `package.json` and `.github/workflows/ci.yml` when choosing verification +- Current diff with `git status --short`, `git diff --name-only`, and focused file reads + +## Title Prefixes + +Prefer the same prefixes used by the labeler: + +- `[FEAT]` new feature +- `[FIX]` bug fix +- `[REFACTOR]` refactor +- `[API]` API integration +- `[DOCS]` documentation +- `[STYLE]` UI/style-only polish +- `[TEST]` tests +- `[SETTING]` config/setup +- `[DEVELOP]` deploy/development workflow +- `[CROSSBROWSING]` browser compatibility + +## Branch And Commit Conventions + +Use `.agents/skills/project-conventions-workflow/SKILL.md` for branch names, commit formats, examples, commit types, and commit body rules. Do not restate those rules here. + +## PR Body Rules + +- Base the summary on the actual diff, not intent alone. +- Link issues in the `ISSUE` section with `close #123` or `refs #123` only when a real issue number is known. +- Keep `What is this PR?` focused on user-visible behavior and key implementation decisions. +- In `Screenshot`, add screenshots or GIF notes only if they exist. Otherwise write `N/A` with a short reason. +- Fill `Test Checklist` with actual checks, such as `pnpm format:check`, `pnpm lint`, `pnpm build`, or manual route checks. +- Mark unchecked items for checks that still need to be run. +- Do not claim CI, deployment, screenshots, or tests passed without evidence. +- If CodeRabbit, Copilot, or teammate review already covered a finding, do not repeat the same comment; summarize only unresolved review risk or the PR point reviewers should focus on. +- For non-trivial frontend PRs, include a short reviewer focus such as Server/Client Component boundary, query key/cache behavior, shared component ownership, or verification gap. + +## Suggested Body + +```markdown +## ISSUE + +close # + +

+ +## What is this PR? + +- ... + +

+ +## Screenshot + +N/A + +

+ +## Test Checklist + +- [x] `pnpm lint` +- [ ] `pnpm build` +``` + +## Verification Guidance + +Use `frontend-quality-verification` after code changes. CI currently runs: + +- `pnpm format:check` +- `pnpm lint` +- `pnpm build` + +For docs-only PRs, `git diff --check` is usually enough locally unless the user wants CI parity. + +CodeRabbit is configured by `.coderabbit.yaml` to review `develop` and `main` PRs, skip draft PRs, and use `AGENTS.md` plus `.agents/skills/**/SKILL.md` as code guidelines. Treat its output as review input, not as proof that local verification passed. + +## Done Criteria + +- PR title and body match the actual change. +- Related issue is linked when known. +- Screenshot section is honest. +- Test checklist distinguishes completed and pending checks. +- CodeRabbit or teammate review comments are not duplicated without new evidence. +- Residual risk is stated when verification is incomplete. diff --git a/.agents/skills/pr-prep-workflow/agents/openai.yaml b/.agents/skills/pr-prep-workflow/agents/openai.yaml new file mode 100644 index 0000000..46f21f8 --- /dev/null +++ b/.agents/skills/pr-prep-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'PR Prep Workflow' + short_description: 'Prepare PR text from the local diff.' + default_prompt: '$pr-prep-workflow Prepare a PR title, body, and test checklist from the current diff.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/project-conventions-workflow/SKILL.md b/.agents/skills/project-conventions-workflow/SKILL.md new file mode 100644 index 0000000..c720e41 --- /dev/null +++ b/.agents/skills/project-conventions-workflow/SKILL.md @@ -0,0 +1,119 @@ +--- +name: project-conventions-workflow +description: Use this when applying, checking, or explaining Roominus Admin project conventions for code naming, Next.js pages/components, assets, CSS units, branch names, commit messages, PR preparation, or implementation review. +--- + +# Project Conventions Workflow + +## Purpose + +Apply Roominus Admin conventions consistently while keeping them compatible with this Next.js 16 App Router project. + +## Read First + +- `AGENTS.md` +- Nearby files for local naming/style patterns +- `package.json` for available checks +- For Next-specific pages/layouts, the relevant local docs in `node_modules/next/dist/docs/01-app/` + +## Code Naming + +- Name page and component files/components with PascalCase when creating project-owned UI modules. + - Examples: `LoginPage.tsx`, `UserTable.tsx`, `RoomInUsCard.tsx` + - Keep required Next route convention files lowercase: `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, `route.ts`. + - Prefer route entries that render PascalCase page components when the page becomes non-trivial. +- Name icon and image asset files with camelCase. + - Icons: `plusIcon.svg`, `userMenuIcon.svg` + - Images: `roomInUs.png`, `loginBanner.png` +- Use SVG for icons and PNG for raster images unless an external source or optimization need clearly requires another format. +- Import SVGs as React components through the existing SVGR webpack setup when component control is needed. +- Use lucide icons for generic UI actions when a suitable icon exists; use local SVG assets for brand/product-specific icons. + +## Next.js Structure + +- Keep `src/app` for routing files and route shell composition. +- Put reusable components under `src/shared/components`. +- Put reusable utilities under `src/shared/lib`, hooks under `src/shared/hooks`, and types under `src/shared/types`. +- Put domain-specific code under `src/features` only when it has enough logic or reuse to justify a feature folder. +- Keep route-local components close to their route until reuse is proven. +- Pages and layouts are Server Components by default. Add `'use client'` only for state, handlers, effects, browser APIs, or client hooks. +- In Next 16 App Router pages, treat `params` and `searchParams` as promises. + +## Styling + +- Prefer the project's existing Tailwind/shadcn/Radix patterns over introducing `styled-components`. +- Use `em`, `%`, or other relative/container-based units for layout and scalable sizing when practical. +- Use `px` for format-like constants such as `border-width`, `border-radius`, hairlines, and tiny fixed offsets. +- Keep admin screens dense, readable, and task-focused. +- Avoid making a reusable abstraction only because two styles look similar once. + +## Branch Naming + +- Production branch: `main` +- Development branch: `develop` +- Work branches: `/-` + - Example: `feature/3-login-layout` +- Branch names must never include `#`, Korean characters, spaces, underscores, or camelCase. +- The issue number segment is digits only, without `#`; use `feature/5-login-layout`, not `feature/#5-login-layout`. +- The slug after the issue number must use only English lowercase letters, numbers, and hyphens. +- Use lowercase branch types such as `feature`, `fix`, `refactor`, `docs`, `style`, `test`, `setting`, `chore`. +- When no issue number exists, ask the user to create or link an issue before proposing a work branch. + +## Commit Messages + +Use this format: + +```text +Type: 한글 변경 요약 (#issue) +``` + +Example: + +```text +Feat: 카카오 로그인 기능 구현 (#9) +``` + +Allowed commit types: + +| Type | Meaning | +| ---------- | -------------------------------------------- | +| `Feat` | New feature | +| `Fix` | Bug fix | +| `Remove` | File, code, or feature removal | +| `Chore` | Build or maintenance changes | +| `Test` | Test changes | +| `Refactor` | Refactoring | +| `Docs` | Documentation | +| `Style` | Style or formatting without behavior changes | +| `Setting` | Environment/config setup | + +Rules: + +- Use the English type exactly as shown. +- Write the summary in Korean when possible. +- Do not end the subject with a period. +- Keep the subject short; aim for 50 English characters or similar visual length. +- Separate subject and body with a blank line when adding a body. +- In the body, explain what changed and why, not every mechanical detail. +- Use bullets for multiple body points. + +## PR And Issue Alignment + +- Match commit type and PR/issue title prefix when practical: + - `Feat` -> `[FEAT]` + - `Fix` -> `[FIX]` + - `Refactor` -> `[REFACTOR]` + - `Docs` -> `[DOCS]` + - `Style` -> `[STYLE]` + - `Test` -> `[TEST]` + - `Setting` -> `[SETTING]` +- Keep issue TODO items and PR test checklist checkable. +- Do not claim screenshots, tests, CI, or deployment evidence unless it exists. + +## Done Criteria + +- New code follows naming and asset conventions. +- Next.js required file names are not renamed to PascalCase. +- Styling unit choices follow the relative-unit preference while preserving practical `px` use. +- Branch suggestions omit `#`, Korean characters, spaces, underscores, and camelCase. +- Any deviation is intentional and explained. diff --git a/.agents/skills/project-conventions-workflow/agents/openai.yaml b/.agents/skills/project-conventions-workflow/agents/openai.yaml new file mode 100644 index 0000000..7f9b044 --- /dev/null +++ b/.agents/skills/project-conventions-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Project Conventions Workflow' + short_description: 'Apply Roominus Admin code, branch, and commit conventions.' + default_prompt: '$project-conventions-workflow Apply the Roominus Admin code, branch, and commit conventions.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/server-client-boundary-workflow/SKILL.md b/.agents/skills/server-client-boundary-workflow/SKILL.md new file mode 100644 index 0000000..73c1eef --- /dev/null +++ b/.agents/skills/server-client-boundary-workflow/SKILL.md @@ -0,0 +1,114 @@ +--- +name: server-client-boundary-workflow +description: Use this when deciding or reviewing Next.js Server and Client Component boundaries, including App Router pages, layouts, providers, React Query usage, browser APIs, third-party interactive libraries, serializable props, and long-term bundle or maintainability risks from 'use client' placement. +--- + +# Server Client Boundary Workflow + +## Purpose + +Decide where Roominus Admin should use Server Components and where it should introduce a Client Component boundary. + +The goal is durable boundaries: small client islands, server-owned data, predictable imports, and client bundles that do not grow just because one nested interaction exists. + +## Read First + +- `AGENTS.md` +- `node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md` +- Nearby route, component, provider, hook, and API files +- `.agents/skills/page-feature-workflow/SKILL.md` when changing a route + +## Default Rule + +Keep pages, layouts, and non-interactive UI as Server Components by default. Add `'use client'` only at the smallest component boundary that needs client-only behavior. + +## Decision Order + +Apply these checks in order. Start from the smallest browser-only behavior, not from whether the page feels interactive. + +1. Keep the route shell server-rendered unless the route file itself must own client state or browser APIs. +2. Fetch route data on the server when the data can be known before interaction and does not depend on client-only session state. +3. Pass server-fetched data into interactive children as serializable props. +4. Isolate event handlers, local state, effects, browser APIs, React Query hooks, and providers into the smallest client component that owns them. +5. Keep reusable presentational components server-compatible unless their public API is explicitly interactive. +6. Use a small client wrapper for third-party components that require the browser. +7. Review imports after adding `'use client'`; every imported module now has to be safe for the client bundle. + +## Use A Client Component When + +- The component uses React state, such as `useState` or `useReducer`. +- The component has event handlers, such as `onClick`, `onChange`, or form submit handlers. +- The component uses lifecycle or effect hooks, such as `useEffect`. +- The component reads browser-only APIs, such as `window`, `document`, `localStorage`, or geolocation. +- The component uses custom hooks that depend on client state, effects, browser APIs, or React Query. +- The component provides React context. +- A third-party component requires client-only features and does not already declare its own client boundary. + +## Prefer A Server Component When + +- The component fetches data on the server or can receive server-fetched data as props. +- The component needs secrets, private environment variables, tokens, cookies, or server-only APIs. +- The component mainly renders static or data-driven markup. +- Keeping it server-side reduces client JavaScript without hurting interaction. +- It composes a small interactive child component inside a larger static shell. +- It is a shared presentational component that should stay usable from either Server or Client Components. + +## Boundary Placement Rules + +- Put `'use client'` in a leaf or narrow wrapper component, not in a whole page or layout unless the whole surface truly needs it. +- Once a file has `'use client'`, its imports and child components join the client bundle, so avoid importing server-only modules from it. +- Pass serializable props from Server Components to Client Components. +- Pass Server Components as `children` into Client Components when an interactive shell needs server-rendered content inside it. +- Render providers as deep as practical so static layout pieces can remain server-rendered. +- For feature pages with multiple client concerns, prefer a server page/shell plus a feature-local client provider or controller that owns search, filters, pagination, modal state, geolocation, maps, and React Query orchestration. +- Keep section components under that provider focused on rendering and user events. Let them read the smallest needed state through selector-style hooks or narrow contexts instead of forcing the page/shell to become client-only. +- Keep TanStack Query hooks, browser storage, and event-heavy behavior behind a client boundary. +- Keep server-only modules out of any file that might be imported by a Client Component. +- Do not move a component to the client only to satisfy one child; move that child or a wrapper instead. +- Do not make a shared component client-only for styling, layout, icons, class merging, or static composition. + +## Long-Term Guardrails + +- Treat `'use client'` as a bundle boundary, not a convenience flag. +- Prefer server shells with client islands: one form, modal, table controller, provider, or button group can be client-rendered inside a server-rendered page. +- Keep Client Components thin: own interaction and call client hooks, but avoid embedding data shaping, route policy, or backend contract details there. +- When a client provider is needed, split memoized context values by concern, such as `search`, `table`, `pagination`, `map`, or `marketList`, so one state update does not unnecessarily churn unrelated consumers. +- Keep Server Components explicit about data ownership: fetch, normalize, and pass stable props rather than leaking transport shapes through the tree. +- When a shared component grows event-heavy behavior, split a server-compatible presentational component from a client controller component. +- If a boundary decision is unclear, choose the option that keeps fewer modules in the client bundle and revisit after real interaction requirements appear. + +## Red Flags + +- A `page.tsx` or `layout.tsx` starts with `'use client'` only because one nested component needs `onClick`. +- A Client Component imports API helpers that read private environment variables, server cookies, filesystem, database clients, or token-bearing server logic. +- A shared UI primitive becomes client-only because one usage needed state. +- React Query is used for data that could be fetched by the route before first render without losing required interactivity. +- A server-fetched object with functions, class instances, non-normalized Dates, Maps, Sets, or other non-serializable values is passed to a Client Component. +- A provider wraps the entire document when only a route section needs the context. + +## Review Severity + +- Critical: server secrets or server-only modules can enter a client bundle; build/runtime failure from an invalid boundary; non-serializable props cross the boundary. +- Warning: a page, layout, or large shared shell is marked client-only without a route-level need; React Query replaces simple server data fetching; a provider is much higher than needed. +- Suggestion: a smaller client wrapper or server-compatible presentational split would reduce future bundle growth. + +## Review Checklist + +1. Identify the first line that requires client-only behavior. +2. Move that behavior into the smallest component that owns it. +3. Keep data fetching and secret-bearing logic on the server when possible. +4. Check that props crossing from server to client are serializable. +5. Check that client files do not import server-only helpers, private environment logic, filesystem code, database code, or token-bearing API helpers. +6. Check whether a third-party client-only component needs a small wrapper. +7. Check that shared components remain server-compatible unless their purpose is interaction. +8. Check that imports use concrete paths when a broad barrel import would pull unrelated shared component modules into a client graph. +9. Verify with `pnpm build` when boundary changes affect routes, providers, or imports. + +## Done Criteria + +- Server and Client Component boundaries are intentional and minimal. +- Client bundles are not expanded by marking large static shells as client code. +- Server-only data, secrets, and private environment variables do not cross into client files. +- Client Components receive serializable props. +- Shared presentational components stay server-compatible by default. +- Verification is run or the skipped check is explained. diff --git a/.agents/skills/server-client-boundary-workflow/agents/openai.yaml b/.agents/skills/server-client-boundary-workflow/agents/openai.yaml new file mode 100644 index 0000000..a87e57d --- /dev/null +++ b/.agents/skills/server-client-boundary-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Server Client Boundary Workflow' + short_description: 'Decide durable Next Server and Client Component boundaries.' + default_prompt: '$server-client-boundary-workflow로 서버/클라이언트 컴포넌트 경계를 장기적으로 안전하게 판단해줘.' +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/shared-component-workflow/SKILL.md b/.agents/skills/shared-component-workflow/SKILL.md new file mode 100644 index 0000000..daffd36 --- /dev/null +++ b/.agents/skills/shared-component-workflow/SKILL.md @@ -0,0 +1,57 @@ +--- +name: shared-component-workflow +description: Use this when adding or changing reusable Roominus Admin components under src/shared/components. +--- + +# Shared Component Workflow + +## Purpose + +Create or update reusable UI components without promoting route-specific code too early. + +Use this for components that are already needed by more than one page, layout, or feature. If the component is only used by one route, keep it route-local and use `page-feature-workflow` instead. + +## Read First + +- `AGENTS.md` +- `.agents/skills/project-conventions-workflow/SKILL.md` +- Existing components in `src/shared/components` +- Usage sites that will import the component + +## Required Inputs + +- Component name in PascalCase +- Intended usage sites +- Props and states: default, loading, disabled, empty, error, invalid, or selected +- Accessibility expectations: label, role, keyboard behavior, focus behavior + +If the reuse case is unclear, keep the component local until another real usage appears. + +## Rules + +- Put shared UI under `src/shared/components`. +- Use local route or feature components when copy, route behavior, analytics, or API details are specific to one screen. +- Keep public props small and predictable. +- Prefer composition over boolean props when variants would multiply quickly. +- Use lucide icons for generic UI actions when available. +- Preserve existing shadcn/Radix-style primitives and Tailwind patterns. +- Use `cva` for repeated variant, size, or state class sets, and `cn`/`tailwind-merge` when merging caller-provided `className`. +- Do not introduce Vanilla Extract or `*.css.ts`; this project uses Tailwind CSS 4, shadcn/Radix-style primitives, and global CSS only where needed. +- Avoid nested cards and layout abstractions that hide page structure. + +## Implementation Flow + +1. Confirm the component has more than one real usage or a near-certain reuse path. +2. Read the closest existing component pattern. +3. Define props around user-visible state and behavior, not backend shape. +4. Implement the component with stable layout dimensions and responsive constraints. +5. Wire exports only where the project already uses barrel exports or direct imports. +6. Update all usage sites and run targeted verification. + +## Done Criteria + +- The component belongs in `src/shared/components`. +- Props are narrow, named clearly, and do not expose route-specific details. +- Loading, disabled, error, and focus states are handled when relevant. +- Text fits at common admin viewport widths. +- Verification is run or the skipped check is explained. diff --git a/.agents/skills/shared-component-workflow/agents/openai.yaml b/.agents/skills/shared-component-workflow/agents/openai.yaml new file mode 100644 index 0000000..aba0ebc --- /dev/null +++ b/.agents/skills/shared-component-workflow/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Shared Component Workflow' + short_description: 'Add or change reusable shared UI components.' + default_prompt: '$shared-component-workflow로 Roominus Admin 공용 컴포넌트 작업을 진행해줘.' +policy: + allow_implicit_invocation: true diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..969edd4 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,232 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: 'ko-KR' +early_access: false + +tone_instructions: | + 한국어로 간결하고 근거 중심의 코드 리뷰를 제공해 주세요. + 보안, 런타임 오류, Next.js Server/Client Component 경계, 접근성, 유지보수성, 검증 누락을 우선합니다. + 단순 취향이나 포맷터가 처리할 수 있는 문제는 중요 이슈처럼 다루지 마세요. + 이 저장소의 기준은 AGENTS.md와 .agents/skills/**/SKILL.md를 우선 적용합니다. + +reviews: + profile: 'assertive' + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + review_details: false + assess_linked_issues: false + related_prs: false + suggested_labels: true + sequence_diagrams: false + changed_files_summary: true + high_level_summary_in_walkthrough: true + high_level_summary_placeholder: '이 PR의 주요 변경사항을 요약합니다.' + collapse_walkthrough: true + commit_status: true + fail_commit_status: false + related_issues: false + abort_on_close: true + + path_filters: + - 'AGENTS.md' + - 'README.md' + - 'docs/**' + - '.agents/**' + - '.github/**' + - 'src/**' + - 'public/**' + - 'package.json' + - 'pnpm-workspace.yaml' + - 'next.config.*' + - 'postcss.config.*' + - 'eslint.config.*' + - 'components.json' + - 'tsconfig*.json' + - '.prettierrc' + - '.prettierignore' + - '.node-version' + - '!.coderabbit.yaml' + - '!node_modules/**' + - '!.next/**' + - '!out/**' + - '!build/**' + - '!coverage/**' + - '!*.lock' + - '!pnpm-lock.yaml' + - '!package-lock.json' + - '!bun.lockb' + - '!**/*.png' + - '!**/*.jpg' + - '!**/*.jpeg' + - '!**/*.gif' + - '!**/*.webp' + - '!**/*.ico' + - '!**/*.avif' + - '!**/*.mp4' + - '!**/*.webm' + - '!**/*.mp3' + - '!**/*.ogg' + + path_instructions: + - path: 'src/**/*.ts' + instructions: | + TypeScript 코드 리뷰 기준: + - type-only import가 값 import로 작성되지 않았는지 확인합니다. + - import 순서는 외부 라이브러리, 내부 절대경로(@/*), 상대경로, 스타일 파일 흐름을 따르는지 확인합니다. + - var 사용, 불필요한 let, 사용하지 않는 변수와 매개변수를 지적합니다. + - @/* alias가 현재 tsconfig의 프로젝트 루트 기준 alias와 맞는지 확인하고, 존재하지 않는 alias를 제안하지 않습니다. + - API 응답처럼 확장될 수 있는 객체 계약은 interface, 단순 조합 타입은 type을 우선 고려합니다. + - boolean 값은 is, has, can, should 같은 의미 있는 접두사를 사용하는지 확인합니다. + - custom hook은 use* 이름을 따르는지 확인합니다. + + - path: 'src/**/*.tsx' + instructions: | + React/TSX 코드 리뷰 기준: + - project-owned 컴포넌트 파일명은 PascalCase를 따르고, Next route convention 파일(page.tsx, layout.tsx 등)은 예외로 둡니다. + - Next route convention 파일은 default export가 필요하지만, 일반 컴포넌트와 유틸 export는 named export를 우선합니다. + - props 타입은 컴포넌트 가까이에 두고 ComponentNameProps처럼 읽기 쉬운 이름을 우선합니다. + - 이벤트 핸들러는 handle* 이름을 사용해 동작 의도를 드러내는지 확인합니다. + - Tailwind class 조합은 기존 shadcn/Radix/cva 패턴을 우선하고, 조건부 class 병합에는 cn/tailwind-merge 사용을 확인합니다. + - class 순서만을 위한 리뷰는 피합니다. 이 프로젝트는 prettier-plugin-tailwindcss가 class 정렬을 담당합니다. + - arbitrary value나 하드코딩 색상은 실제 디자인 토큰/레이아웃 제약이 없을 때만 허용하고, 반복되면 CSS 변수나 cva variant로 정리하도록 제안합니다. + - 버튼과 링크 의미, label 연결, 키보드 포커스, ARIA 사용 같은 접근성 문제를 우선합니다. + - useMemo, useCallback, React.memo는 실제 비용이나 참조 안정성 문제가 있을 때만 제안합니다. + - Next.js App Router 프로젝트이므로 server/client boundary와 route 파일 규칙을 함께 확인합니다. + + - path: 'src/app/**' + instructions: | + Next.js App Router 변경 리뷰 기준: + - Next.js 16 기준으로 params/searchParams를 Promise로 다루는지 확인합니다. + - page.tsx와 layout.tsx는 기본 Server Component로 유지하고, 'use client'는 최소 client island에만 두는지 확인합니다. + - route-local UI, src/features, src/shared의 책임 경계가 섞이지 않았는지 확인합니다. + - loading, empty, error, disabled, success 상태가 필요한 흐름에서 빠지지 않았는지 확인합니다. + + - path: 'src/shared/components/**' + instructions: | + 공유 컴포넌트 리뷰 기준: + - 여러 실제 사용처가 있는지, route-specific copy/API/권한/analytics가 새지 않았는지 확인합니다. + - 기본적으로 Server Component에서도 사용할 수 있는 presentational API인지 확인합니다. + - interactive behavior가 필요하면 client controller와 presentational component를 분리할 수 있는지 확인합니다. + - variant, size, state class는 cva를 우선하고, 외부 className 병합은 cn/tailwind-merge로 충돌을 줄이는지 확인합니다. + - Tailwind utility가 반복되어 컴포넌트 API를 흐리면 cva variant 또는 작은 presentational component 분리를 제안합니다. + - 고정 px 폭/높이, 긴 텍스트 overflow, 모바일 터치 영역 부족, dark/disabled/focus-visible 상태 누락을 확인합니다. + - 접근성(label, role, keyboard, focus-visible)과 responsive text overflow를 확인합니다. + + - path: 'src/app/globals.css' + instructions: | + Tailwind 전역 스타일 리뷰 기준: + - Tailwind CSS 4와 @tailwindcss/postcss 기준에서 동작하는 문법인지 확인합니다. + - 전역 CSS에는 reset, theme token, CSS variable, base layer처럼 앱 전체에 필요한 규칙만 둡니다. + - 특정 페이지나 컴포넌트에만 필요한 스타일은 globals.css 대신 해당 컴포넌트의 Tailwind class/cva로 유지하도록 제안합니다. + - 색상, radius, spacing token 변경은 shadcn/Radix 컴포넌트와 dark mode에 미치는 영향을 함께 확인합니다. + - Vanilla Extract나 *.css.ts 전제를 적용하지 않습니다. 이 프로젝트는 Tailwind/shadcn/cva 기반입니다. + + - path: 'src/shared/hooks/**' + instructions: | + hook 리뷰 기준: + - hook 이름이 use* 형태이고 책임이 하나의 상태/효과/흐름으로 설명되는지 확인합니다. + - React Query, browser API, effect 의존성이 명확한 client boundary 안에서만 쓰이는지 확인합니다. + - 반환값이 호출부에서 예측 가능한 object 또는 tuple 형태인지 확인합니다. + + - path: 'src/shared/lib/**' + instructions: | + 유틸/API 경계 리뷰 기준: + - 순수 계산, mapping, formatting은 React state 없이 테스트 가능하게 유지되는지 확인합니다. + - API helper는 request/response 타입과 오류 처리를 API boundary 가까이에 두는지 확인합니다. + - private env, token, cookie, server-only 로직이 Client Component로 import될 수 있는 경로에 들어가지 않았는지 확인합니다. + + - path: 'src/features/**' + instructions: | + feature 코드 리뷰 기준: + - 도메인별 책임이 src/app route shell이나 src/shared generic component로 새지 않았는지 확인합니다. + - API hook, form state, UI state, presentational UI가 과하게 결합되지 않았는지 확인합니다. + - shared로 올린 코드가 실제 재사용 근거를 갖는지 확인합니다. + + - path: '.agents/**' + instructions: | + agent skill 리뷰 기준: + - AGENTS.md와 skill 간 source of truth가 중복되거나 충돌하지 않는지 확인합니다. + - skill frontmatter description이 실제 trigger 상황을 충분히 담는지 확인합니다. + - 새 skill은 현재 단일 Next Admin 앱에 필요한 범위인지 확인하고, Jira/Turbo/모노레포 전제를 들여오지 않았는지 확인합니다. + + - path: '.github/workflows/**' + instructions: | + GitHub Actions 리뷰 기준: + - pnpm-lock.yaml과 package.json script 기준으로 실제 실행 가능한 명령인지 확인합니다. + - Node/pnpm 버전 기준이 .node-version과 packageManager 선언을 따르는지 확인합니다. + - 캐시, 권한, concurrency가 필요한 최소 범위인지 확인합니다. + + - path: 'docs/**' + instructions: | + 문서 리뷰 기준: + - 실제 프로젝트 구조와 다른 미래 전제나 외부 레포 전제가 들어오지 않았는지 확인합니다. + - 실행한 검증과 실행하지 못한 검증을 구분하는지 확인합니다. + - 앱 동작 변경과 문서/agent-only 변경의 경계를 명확히 적는지 확인합니다. + + auto_review: + enabled: true + drafts: false + base_branches: + - 'develop' + - 'main' + labels: + - '!wip' + - '!draft' + - '!skip-review' + ignore_usernames: + - 'dependabot' + - 'renovate' + ignore_title_keywords: + - '[docs]' + - '[skip-review]' + + pre_merge_checks: + title: + mode: 'warning' + requirements: 'PR 제목은 .github/labeler.yml의 prefix 규칙을 따릅니다. 예: [SETTING] CI 기준 정리' + description: + mode: 'warning' + issue_assessment: + mode: 'off' + + finishing_touches: + docstrings: + enabled: false + unit_tests: + enabled: false + + tools: + eslint: + enabled: true + languagetool: + enabled: true + level: 'default' + markdownlint: + enabled: true + gitleaks: + enabled: true + yamllint: + enabled: true + actionlint: + enabled: true + +chat: + art: false + auto_reply: true + +knowledge_base: + web_search: + enabled: true + code_guidelines: + enabled: true + filePatterns: + - 'AGENTS.md' + - 'README.md' + - 'docs/**/*.md' + - '.agents/skills/**/SKILL.md' + - '.github/pull_request_template.md' + - '.github/labeler.yml' + - 'package.json' + - 'tsconfig.json' + - 'next.config.ts' diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..88ef789 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +out +.git +.github +.env* +*.log +.DS_Store \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 362a7f0..f4ad4f5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,11 +1,10 @@ --- name: Bug report about: Create a report to help us improve -title: "[FIX] " +title: '[FIX] ' labels: "\U0001F41E BugFix" assignees: hdg0116 type: Bug - --- ## 어떤 버그인가요? diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md index ee8833f..f54d768 100644 --- a/.github/ISSUE_TEMPLATE/custom.md +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -4,7 +4,6 @@ about: Describe this issue template's purpose here. title: '' labels: '' assignees: '' - --- ## 개발 유형 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index cde9de3..8aaab41 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,11 +1,10 @@ --- name: Feature request about: Suggest an idea for this project -title: "[FEAT] " -labels: "✨ Feature" +title: '[FEAT] ' +labels: '✨ Feature' assignees: hdg0116 type: Feature - --- ### 🛠️ 만들고자 한 기능 설명 diff --git a/.github/actions/labeler/action.yml b/.github/actions/labeler/action.yml new file mode 100644 index 0000000..62e5ef7 --- /dev/null +++ b/.github/actions/labeler/action.yml @@ -0,0 +1,16 @@ +name: Auto Labeler +author: Jim Schubert +description: Automatically label issues and pull requests via configuration + +inputs: + GITHUB_TOKEN: + description: GitHub token for the repository + required: false + config_path: + description: Configuration path to labeler config, relative to repository root + required: false + default: .github/labeler.yml + +runs: + using: docker + image: docker://jimschubert/labeler-action@sha256:19862087fca3e5a5752b120abb1f18c6502604c2fb28f505be682d5c7806bfb4 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..d18857e --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,44 @@ +enable: + issues: true + prs: true + +labels: + '✨ Feature': + include: + - '(?i)^\[FEAT\]' + + '🐞 BugFix': + include: + - '(?i)^\[FIX\]' + + '🔨 Refactor': + include: + - '(?i)^\[REFACTOR\]' + + '📬 API': + include: + - '(?i)^\[API\]' + + '📃 Docs': + include: + - '(?i)^\[DOCS\]' + + '🌏 Deploy': + include: + - '(?i)^\[DEVELOP\]' + + '🎨 Style': + include: + - '(?i)^\[STYLE\]' + + '💻 CrossBrowsing': + include: + - '(?i)^\[CROSSBROWSING\]' + + '✅ Test': + include: + - '(?i)^\[TEST\]' + + '⚙ Setting': + include: + - '(?i)^\[SETTING\]' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2c93f1d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## ISSUE 🔗 + + + +

+ +## What is this PR? 🔍 + + + +

+ +## Screenshot 📷 + + + +

+ +## Test Checklist ✔ + + + +- [ ] TODO +- [ ] TODO +- [ ] TODO diff --git a/.github/workflows/automatic-assign-reviewers.yml b/.github/workflows/automatic-assign-reviewers.yml new file mode 100644 index 0000000..7fb69fd --- /dev/null +++ b/.github/workflows/automatic-assign-reviewers.yml @@ -0,0 +1,18 @@ +name: Review Assign + +on: + pull_request: + types: [opened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + assign: + runs-on: ubuntu-latest + steps: + - uses: hkusu/review-assign-action@v1 + with: + assignees: ${{ github.actor }} # assign pull request author + reviewers: hdg0116 # if draft, assigned when draft is released diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e558a79 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality: + name: Quality Check + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: .node-version + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check formatting + run: pnpm format:check + + - name: Lint + run: pnpm lint + + - name: Build + run: pnpm build diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..67711a3 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,66 @@ +name: Build and Deploy Admin App (dev) + +on: + push: + branches: [develop] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: room-in-us/room-in-us-front-admin # ← admin + +jobs: + build-and-push: + runs-on: ubuntu-latest + environment: dev + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,format=short + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + environment: dev + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.OCI_HOST }} + username: ${{ secrets.OCI_USER }} + key: ${{ secrets.OCI_SSH_KEY }} + command_timeout: 10m # ← 추가 (기본 10분이지만 명시) + script: | + cd ~/deploy + docker compose pull --quiet admin-app + docker compose up -d --no-deps admin-app + docker image prune -f diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..cdc6961 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,134 @@ +name: Build and Deploy Admin App (prod) + +on: + push: + branches: [main] + workflow_dispatch: # 첫 배포 검증 / 롤백 재실행용 + +# 배포 중 취소는 컨테이너가 없는 상태를 만들 수 있으므로 false +concurrency: + group: deploy-prod-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: room-in-us/room-in-us-front-admin + # dev 가 latest 를 쓰므로 prod 는 반드시 분리할 것. + # 같은 태그를 쓰면 develop 푸시가 운영 이미지를 덮어씁니다. + PROD_IMAGE_TAG: prod-latest + +jobs: + build-and-push: + # 운영 인스턴스가 aarch64(OCI Ampere)이므로 arm64 네이티브 러너에서 빌드합니다. + # QEMU 에뮬레이션 대비 훨씬 빠릅니다. public 레포는 이 러너가 무료입니다. + runs-on: ubuntu-24.04-arm + environment: prod + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ env.PROD_IMAGE_TAG }} + type=sha,format=short,prefix=prod- + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + environment: prod + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # compose 파일의 원본은 레포입니다. 서버에는 배포할 때마다 덮어씁니다. + # 서버에서 직접 수정하지 마세요 — 다음 배포 때 되돌아갑니다. + - name: Sync compose file to server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.OCI_HOST }} + username: ${{ secrets.OCI_USER }} + key: ${{ secrets.OCI_SSH_KEY }} + source: 'deploy/prod/docker-compose.yml' + target: '~/roominus/prod/frontend-admin' + strip_components: 2 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.OCI_HOST }} + username: ${{ secrets.OCI_USER }} + key: ${{ secrets.OCI_SSH_KEY }} + command_timeout: 10m + script: | + set -eu + + DIR="$HOME/roominus/prod/frontend-admin" + SERVICE="frontend-admin" + + echo "=== Logging into GHCR ===" + echo "${{ secrets.GITHUB_TOKEN }}" \ + | docker login ${{ env.REGISTRY }} -u "${{ github.actor }}" --password-stdin + + cd "$DIR" + + echo "=== Pulling image ===" + docker compose pull --quiet "$SERVICE" + + echo "=== Recreating container ===" + docker compose up -d --no-deps "$SERVICE" + + echo "=== Health check ===" + OK=0 + for i in $(seq 1 20); do + if docker exec "$SERVICE" node -e " + require('http').get('http://127.0.0.1:3000/', r => { + process.exit(r.statusCode < 500 ? 0 : 1) + }).on('error', () => process.exit(1)) + " 2>/dev/null; then + OK=1; echo "✓ responding (attempt $i)"; break + fi + sleep 3 + done + + if [ "$OK" != "1" ]; then + echo "✗ 컨테이너가 응답하지 않습니다" + docker compose logs --tail 80 "$SERVICE" || true + exit 1 + fi + + docker compose ps + docker image prune -f + echo "✓ Deployment complete!" diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..5e3b22b --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,32 @@ +name: PR labeler + +on: + issues: + types: [opened] + pull_request_target: + types: [opened, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + labeler: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Check Labels + id: labeler + uses: ./.github/actions/labeler + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + config_path: .github/labeler.yml diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.18.0 diff --git a/.prettierignore b/.prettierignore index 90888ec..9f2b69a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ node_modules dist coverage -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +.agents diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..e61d40c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,93 @@ +# Roominus Admin Agent Guide + -# This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +## This Is Not The Next.js You Know + +This project uses Next.js 16. APIs, conventions, and file structure may differ from older Next.js knowledge. Before changing Next-specific code, read the relevant guide in `node_modules/next/dist/docs/` and follow deprecation notices. + +Useful local docs: + +- `node_modules/next/dist/docs/01-app/01-getting-started/02-project-structure.md` +- `node_modules/next/dist/docs/01-app/01-getting-started/03-layouts-and-pages.md` +- `node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md` +- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/page.md` +- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/layout.md` + + +## Project Shape + +- Product: Roominus Admin +- Framework: Next.js App Router under `src/app` +- Runtime scripts: `package.json` +- Package manager source of truth: `package.json` `packageManager` (`pnpm@10.33.0`), existing lockfile, and scripts. This repo currently uses `pnpm-lock.yaml`. +- UI stack: React 19, Tailwind CSS 4, shadcn/Radix-style primitives, lucide icons, `class-variance-authority`, `tailwind-merge` +- Data stack: `axios`, `@tanstack/react-query` +- Path alias: `@/*` maps to project root. + +## Agent Skill Set + +This repo intentionally keeps agent setup light. Use only the skills below unless a task clearly needs a new one. + +| Task Type | Skill | +| -------------------------------------------------- | ---------------------------------------------------------- | +| Decide work type and scope | `.agents/skills/frontend-task-orchestrator/SKILL.md` | +| Apply project code, branch, and commit conventions | `.agents/skills/project-conventions-workflow/SKILL.md` | +| Evolve App Router/domain folder structure | `.agents/skills/app-structure-evolution-workflow/SKILL.md` | +| Add or change App Router pages | `.agents/skills/page-feature-workflow/SKILL.md` | +| Decide Server/Client Component boundaries | `.agents/skills/server-client-boundary-workflow/SKILL.md` | +| Add or change reusable shared UI components | `.agents/skills/shared-component-workflow/SKILL.md` | +| Add form validation and submit flows | `.agents/skills/form-flow-workflow/SKILL.md` | +| Add API helpers or React Query hooks | `.agents/skills/api-integration-workflow/SKILL.md` | +| Review non-trivial frontend diffs | `.agents/skills/frontend-fundamentals-review/SKILL.md` | +| Draft or refine GitHub issues | `.agents/skills/issue-workflow/SKILL.md` | +| Prepare pull request content | `.agents/skills/pr-prep-workflow/SKILL.md` | +| Verify frontend changes | `.agents/skills/frontend-quality-verification/SKILL.md` | + +Do not add Jira, Turbo generator, monorepo, design-system package, PR monitoring, browser review, or performance skills until the repo actually needs them. + +## Working Rules + +- Prefer existing project structure over introducing new folders. +- Keep `src/app` focused on routing. Put reusable UI and utilities under `src/shared`, and domain-specific work under `src/features` when the feature has enough substance to justify it. +- Use `.agents/skills/app-structure-evolution-workflow/SKILL.md` before adopting DONGCHIMI-style route groups or domain-oriented folders. +- Use PascalCase for project-owned page/component modules, but keep required Next route files as `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, and `route.ts`. +- Use camelCase for icon/image asset filenames. +- Use SVG for icons and PNG for raster images unless a concrete need says otherwise. +- Use Tailwind CSS 4 with existing shadcn/Radix-style primitives, `class-variance-authority`, `tailwind-merge`, and `src/app/globals.css`; do not introduce Vanilla Extract or `*.css.ts`. +- Prefer `em`, `%`, or relative units for scalable layout and sizing; use `px` for border width, border radius, hairlines, and small fixed formatting details. +- Pages and layouts are Server Components by default. Add `'use client'` only to components that need state, event handlers, effects, browser APIs, or client hooks. +- Keep `'use client'` at the smallest practical boundary so static shells and server-fetched data stay server-rendered. +- For feature pages with search, pagination, filters, modals, maps, or query orchestration, keep the page/shell server-rendered and move only client-owned state into a narrow feature-local client provider or controller. Render static layout around that client island when possible. +- In Next 16 App Router pages, treat `params` and `searchParams` as promises. +- Keep route-specific components close to the route until reuse is real. +- For API work, keep request/response types near the API boundary and include response-changing inputs in React Query keys. +- Use `rg` or `rg --files` first when searching. +- Use `apply_patch` for manual file edits. +- Do not broaden refactors beyond the requested change. + +## Issue And PR Rules + +- Use `.github/ISSUE_TEMPLATE/*` and `.github/pull_request_template.md` as the source of truth. +- Keep issue and PR text concise, concrete, and tied to observable behavior. +- Prefer these title prefixes so `.github/labeler.yml` can apply labels: `[FEAT]`, `[FIX]`, `[REFACTOR]`, `[API]`, `[DOCS]`, `[STYLE]`, `[TEST]`, `[SETTING]`, `[DEVELOP]`, `[CROSSBROWSING]`. +- Link related issues in the PR `ISSUE` section when one exists. +- Fill the PR test checklist with commands or manual checks actually performed or still required. +- Do not claim screenshots, tests, deployments, or CI results exist unless they were actually produced or checked. +- CodeRabbit review behavior is configured in `.coderabbit.yaml`; treat its comments as review input, not as a substitute for local verification. + +## Branch And Commit Rules + +- Use `.agents/skills/project-conventions-workflow/SKILL.md` as the single detailed source of truth for branch names, commit formats, commit types, character restrictions, and commit body rules. + +## Verification + +Choose the lightest check that proves the change: + +- Docs or agent-only changes: `git diff --check` +- Formatting-sensitive changes: `pnpm format:check` +- Code changes: `pnpm lint` +- Next or type-sensitive changes: `pnpm build` + +If a command cannot be run, report why and note the remaining risk. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b669c9f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:20-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +FROM base AS builder +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN node_modules/.bin/next build --webpack + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/components.json b/components.json index 02e61e0..01d5b8a 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "app/globals.css", + "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" @@ -13,11 +13,11 @@ "iconLibrary": "lucide", "rtl": false, "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" + "components": "@/src/shared/components", + "utils": "@/src/shared/lib/utils", + "ui": "@/src/shared/components/ui", + "lib": "@/src/shared/lib", + "hooks": "@/src/shared/hooks" }, "menuColor": "default", "menuAccent": "subtle", diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml new file mode 100644 index 0000000..fb6b5ae --- /dev/null +++ b/deploy/prod/docker-compose.yml @@ -0,0 +1,52 @@ +# 서버 배치 경로: ~/roominus/prod/frontend-admin/docker-compose.yml +# +# 기존 ~/roominus/prod/docker-compose.yml 은 건드리지 않습니다. +# 그 파일은 frontend 항목이 실물과 어긋나 있고, --env-file 없이 실행하면 +# backend 이미지가 ':' 로 치환되어 서비스가 죽습니다. +# 여기는 독립된 compose 프로젝트로 두고, 네트워크만 공유합니다. + +name: frontend-admin + +services: + frontend-admin: + # 롤백: IMAGE_TAG=prod- docker compose up -d + image: ghcr.io/room-in-us/room-in-us-front-admin:${IMAGE_TAG:-prod-latest} + container_name: frontend-admin + restart: unless-stopped + + # 포트를 호스트에 노출하지 않습니다. + # 앞단 nginx 만 80/443 을 잡고, 내부에서 frontend-admin:3000 으로 접근합니다. + # + # PORT 를 80 으로 바꾸지 마세요. 이 이미지는 USER nextjs(uid 1001)로 + # 실행되어 특권 포트(<1024) 바인딩이 불가능합니다 (EACCES). + environment: + NODE_ENV: production + PORT: '3000' + HOSTNAME: '0.0.0.0' + TZ: Asia/Seoul + + # NEXT_PUBLIC_ 접두사가 없으므로 서버 전용 런타임 변수입니다. + # 빌드 시점에 번들로 들어가지 않으므로 Dockerfile 수정이 필요 없습니다. + ADMIN_API_BASE_URL: ${ADMIN_API_BASE_URL:-https://admin-api.roominus.kr} + + networks: + - roominus + + deploy: + resources: + limits: + cpus: '0.5' + memory: 768M + reservations: + cpus: '0.1' + + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' + +networks: + roominus: + name: prod_roominus-prod-net + external: true diff --git a/docs/agent/index.md b/docs/agent/index.md new file mode 100644 index 0000000..9df6f12 --- /dev/null +++ b/docs/agent/index.md @@ -0,0 +1,31 @@ +# Roominus Admin Agent Setup + +This directory explains the repo-local agent setup for Roominus Admin. + +The setup is intentionally small: + +- Root guide: `AGENTS.md` +- Skills: `.agents/skills/*/SKILL.md` +- Skill metadata: optional `.agents/skills/*/agents/openai.yaml` + +## Included Skills + +| Skill | Purpose | +| --------------------------------- | -------------------------------------------------------------------------- | +| `frontend-task-orchestrator` | Classify a frontend task and choose the right workflow. | +| `project-conventions-workflow` | Apply code naming, asset, style unit, branch, and commit conventions. | +| `page-feature-workflow` | Add or change pages, layouts, and route-local UI in `src/app`. | +| `server-client-boundary-workflow` | Decide durable Next Server and Client Component boundaries. | +| `shared-component-workflow` | Add or change reusable shared UI components. | +| `form-flow-workflow` | Implement validation and submit state for forms. | +| `api-integration-workflow` | Add API helpers, TanStack Query hooks, query keys, and cache behavior. | +| `frontend-fundamentals-review` | Review frontend diffs for maintainability risks. | +| `issue-workflow` | Draft or refine GitHub issues using the repo templates and label prefixes. | +| `pr-prep-workflow` | Prepare PR summaries, issue links, screenshots notes, and test checklists. | +| `frontend-quality-verification` | Pick and run the smallest useful verification set. | + +## Excluded From The Light Setup + +Jira, Figma, Turbo generator, monorepo, design-system package, performance budget, project monitoring, and browser PR review workflows are intentionally not included because this repository is currently a single Next.js Admin app. + +Add those only when the matching workflow becomes part of everyday work. diff --git a/eslint.config.mjs b/eslint.config.mjs index d0a6816..ba29a61 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,6 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import {defineConfig, globalIgnores} from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; import prettier from 'eslint-config-prettier'; const eslintConfig = defineConfig([ @@ -10,11 +10,12 @@ const eslintConfig = defineConfig([ // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', 'node_modules/**', + 'src/shared/api/__generated__/**', ]), ]); diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..1b288f0 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import {clsx, type ClassValue} from 'clsx'; +import {twMerge} from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/next.config.ts b/next.config.ts index 995df5d..3a48dd0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,10 +1,18 @@ -import type { NextConfig } from "next"; +import type {NextConfig} from 'next'; const nextConfig: NextConfig = { - webpack(config) { + output: 'standalone', + webpack(config) { + const assetRule = config.module.rules.find((rule: {test?: RegExp}) => + rule.test?.test?.('.svg') + ); + + if (assetRule && typeof assetRule === 'object') { + assetRule.exclude = /\.svg$/i; + } + config.module.rules.push({ test: /\.svg$/i, - issuer: /\.[jt]sx?$/, use: ['@svgr/webpack'], }); diff --git a/package.json b/package.json index d9846c9..c46c15f 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,18 @@ "name": "roominus-admin", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.33.0", "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", + "api:generate": "swagger-typescript-api generate -p https://admin-api-dev.roominus.kr/api/v3/api-docs -o src/shared/api/__generated__ -n data-contracts.ts --no-client --enum-style union", + "icons:check": "node scripts/check-icons.mjs", + "icons:convert": "node scripts/convert-icons.mjs", + "icons:generate": "node scripts/generate-icons.mjs", "lint": "eslint", - "format": "prettier --write ." + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "@tanstack/react-query": "^5.100.9", @@ -24,6 +30,7 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@svgr/cli": "^8.1.0", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", @@ -34,6 +41,7 @@ "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", + "swagger-typescript-api": "^13.12.6", "tailwindcss": "^4", "typescript": "^5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1677037..fb514b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: specifier: ^1.4.0 version: 1.4.0 devDependencies: + '@svgr/cli': + specifier: ^8.1.0 + version: 8.1.0(typescript@5.9.3) '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.9.3) @@ -75,6 +78,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.8.0 version: 0.8.0(prettier@3.8.3) + swagger-typescript-api: + specifier: ^13.12.6 + version: 13.12.6(react@19.2.4) tailwindcss: specifier: ^4 version: 4.3.0 @@ -88,6 +94,22 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -645,6 +667,23 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@biomejs/js-api@6.0.0': + resolution: {integrity: sha512-8HP7wexjQo5Np1J9h0B2x8L5G0GZpCacgjykxLHtvLPvF0hNqSG754oh8bxo+OSFDpVGMfSjmLO+ZY/5KbfjmQ==} + peerDependencies: + '@biomejs/wasm-bundler': ^2.5.0 + '@biomejs/wasm-nodejs': ^2.5.0 + '@biomejs/wasm-web': ^2.5.0 + peerDependenciesMeta: + '@biomejs/wasm-bundler': + optional: true + '@biomejs/wasm-nodejs': + optional: true + '@biomejs/wasm-web': + optional: true + + '@biomejs/wasm-nodejs@2.5.2': + resolution: {integrity: sha512-B0r7jLdCmXhq4+jnx1oA0/SChLy5G283r35HJr276T+w6qgqAPAhqfnlKs+UobclAfidNDbfj9ZmcuCijaHDxQ==} + '@dotenvx/dotenvx@1.65.0': resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==} hasBin: true @@ -702,6 +741,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1816,6 +1858,11 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@svgr/cli@8.1.0': + resolution: {integrity: sha512-SnlaLspB610XFXvs3PmhzViHErsXp0yIy4ERyZlHDlO1ro2iYtHMWYk2mztdLD/lBjiA4ZXe4RePON3qU/Tc4A==} + engines: {node: '>=14'} + hasBin: true + '@svgr/core@8.1.0': resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} engines: {node: '>=14'} @@ -1830,6 +1877,12 @@ packages: peerDependencies: '@svgr/core': '*' + '@svgr/plugin-prettier@8.1.0': + resolution: {integrity: sha512-o4/uFI8G64tAjBZ4E7gJfH+VP7Qi3T0+M4WnIsP91iFnGPqs5WvPDkpZALXPiyWEtzfYs1Rmwy1Zdfu8qoZuKw==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + '@svgr/plugin-svgo@8.1.0': resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} engines: {node: '>=14'} @@ -1975,6 +2028,12 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/swagger-schema-official@2.0.25': + resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -2158,6 +2217,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -2293,6 +2360,9 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -2314,6 +2384,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2326,6 +2404,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2345,6 +2426,13 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2397,9 +2485,20 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -2482,6 +2581,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + dashify@2.0.0: + resolution: {integrity: sha512-hpA5C/YrPjucXypHPPc0oJ1l9Hf6wWbiOL7Ik42cxnsUOhWiCB/fylKbKqqJalW9FgkNQCw16YO8uW9Hs0Iy1A==} + engines: {node: '>=4'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -2498,6 +2601,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2550,6 +2656,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2558,6 +2667,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2583,6 +2695,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.14: + resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -2667,6 +2782,12 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2809,6 +2930,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eta@3.5.0: + resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} + engines: {node: '>=6.0.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -2839,6 +2964,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2856,6 +2984,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -2944,6 +3075,9 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -3004,6 +3138,10 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3012,6 +3150,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3079,6 +3222,9 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http2-client@1.3.5: + resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -3111,6 +3257,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3550,6 +3700,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3575,6 +3729,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3620,10 +3779,26 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-fetch-h2@2.3.0: + resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} + engines: {node: 4.x || >=6.0.0} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-readfiles@0.2.0: + resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -3638,6 +3813,22 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + oas-kit-common@1.0.8: + resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} + + oas-linter@3.2.2: + resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} + + oas-resolver@2.5.6: + resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} + hasBin: true + + oas-schema-walker@1.1.5: + resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} + + oas-validator@5.0.8: + resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3674,6 +3865,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3693,6 +3887,9 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3760,6 +3957,12 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3775,6 +3978,9 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3854,6 +4060,11 @@ packages: prettier-plugin-svelte: optional: true + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -3910,6 +4121,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3952,6 +4166,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -3960,6 +4178,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + reftools@1.1.9: + resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} + regenerate-unicode-properties@10.2.2: resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} engines: {node: '>=4'} @@ -4102,6 +4323,24 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + should-equal@2.0.0: + resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} + + should-format@3.0.3: + resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} + + should-type-adaptors@1.1.0: + resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} + + should-type@1.4.0: + resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} + + should-util@1.0.1: + resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} + + should@13.2.3: + resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -4245,6 +4484,18 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + swagger-schema-official@2.0.0-bab6bed: + resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + + swagger-typescript-api@13.12.6: + resolution: {integrity: sha512-BFnSbchubRZrxxBRKy506tHBL0//durBph8ZZohZ2RQg/BKOEGm449YMAbSM5DDTwqNpTUPhgCT59S/9d84pdQ==} + engines: {node: '>=20'} + hasBin: true + + swagger2openapi@7.0.8: + resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} + hasBin: true + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -4285,6 +4536,9 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -4315,6 +4569,10 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -4347,6 +4605,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4437,6 +4700,12 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4485,6 +4754,15 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4505,6 +4783,17 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yummies@7.20.1: + resolution: {integrity: sha512-H/AxRy+SjVlfpeI0TkiVkU/M2n+XpR1XGt5yS5r7GsP9Iz3mr17dbq9DLW18PmMlyKO78+yvH/9Z9VAnVuV3og==} + peerDependencies: + mobx: ^6.12.4 + react: ^18 || ^19 + peerDependenciesMeta: + mobx: + optional: true + react: + optional: true + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -4526,6 +4815,25 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5265,6 +5573,12 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/js-api@6.0.0(@biomejs/wasm-nodejs@2.5.2)': + optionalDependencies: + '@biomejs/wasm-nodejs': 2.5.2 + + '@biomejs/wasm-nodejs@2.5.2': {} + '@dotenvx/dotenvx@1.65.0': dependencies: commander: 11.1.0 @@ -5344,6 +5658,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/schemasafe@1.3.0': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -6422,6 +6738,22 @@ snapshots: '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.0) '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.0) + '@svgr/cli@8.1.0(typescript@5.9.3)': + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-prettier': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3) + camelcase: 6.3.0 + chalk: 4.1.2 + commander: 9.5.0 + dashify: 2.0.0 + glob: 8.1.0 + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + '@svgr/core@8.1.0(typescript@5.9.3)': dependencies: '@babel/core': 7.29.0 @@ -6448,6 +6780,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@svgr/plugin-prettier@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + deepmerge: 4.3.1 + prettier: 2.8.8 + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@svgr/core': 8.1.0(typescript@5.9.3) @@ -6586,6 +6924,11 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/swagger-schema-official@2.0.25': {} + + '@types/trusted-types@2.0.7': + optional: true + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': @@ -6751,6 +7094,10 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -6929,6 +7276,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -6951,6 +7302,21 @@ snapshots: bytes@3.1.2: {} + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6968,6 +7334,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + call-me-maybe@1.0.2: {} + callsites@3.1.0: {} camelcase@6.3.0: {} @@ -6981,6 +7349,12 @@ snapshots: chalk@5.6.2: {} + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + citty@0.2.2: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -7021,8 +7395,14 @@ snapshots: commander@7.2.0: {} + commander@9.5.0: {} + concat-map@0.0.1: {} + confbox@0.2.4: {} + + consola@3.4.2: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -7098,6 +7478,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + dashify@2.0.0: {} + data-uri-to-buffer@4.0.1: {} data-view-buffer@1.0.2: @@ -7118,6 +7500,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.23: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -7153,10 +7537,14 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.7: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -7179,6 +7567,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.14: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -7331,6 +7723,10 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.51.0: {} + + es6-promise@3.3.1: {} + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -7548,6 +7944,8 @@ snapshots: esutils@2.0.3: {} + eta@3.5.0: {} + etag@1.8.1: {} eventsource-parser@3.0.8: {} @@ -7621,6 +8019,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.1.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -7643,6 +8043,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -7731,6 +8133,8 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs.realpath@1.0.0: {} + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -7793,6 +8197,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7801,6 +8207,14 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + globals@14.0.0: {} globals@16.4.0: {} @@ -7859,6 +8273,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http2-client@1.3.5: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -7885,6 +8301,11 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} internal-slot@1.1.0: @@ -8249,6 +8670,10 @@ snapshots: dependencies: brace-expansion: 1.1.14 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + minimist@1.2.8: {} ms@2.1.3: {} @@ -8282,6 +8707,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.16: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -8326,12 +8753,24 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-fetch-h2@2.3.0: + dependencies: + http2-client: 1.3.5 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-readfiles@0.2.0: + dependencies: + es6-promise: 3.3.1 + node-releases@2.0.38: {} npm-run-path@4.0.1: @@ -8347,6 +8786,37 @@ snapshots: dependencies: boolbase: 1.0.0 + oas-kit-common@1.0.8: + dependencies: + fast-safe-stringify: 2.1.1 + + oas-linter@3.2.2: + dependencies: + '@exodus/schemasafe': 1.3.0 + should: 13.2.3 + yaml: 1.10.3 + + oas-resolver@2.5.6: + dependencies: + node-fetch-h2: 2.3.0 + oas-kit-common: 1.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.2 + + oas-schema-walker@1.1.5: {} + + oas-validator@5.0.8: + dependencies: + call-me-maybe: 1.0.2 + oas-kit-common: 1.0.8 + oas-linter: 3.2.2 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + reftools: 1.1.9 + should: 13.2.3 + yaml: 1.10.3 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8391,6 +8861,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + ohash@2.0.12: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -8416,6 +8888,8 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8484,6 +8958,10 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -8492,6 +8970,12 @@ snapshots: pkce-challenge@5.0.1: {} + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -8519,6 +9003,8 @@ snapshots: dependencies: prettier: 3.8.3 + prettier@2.8.8: {} + prettier@3.8.3: {} pretty-ms@9.3.0: @@ -8623,6 +9109,11 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -8659,6 +9150,8 @@ snapshots: react@19.2.4: {} + readdirp@5.1.1: {} + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -8678,6 +9171,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + reftools@1.1.9: {} + regenerate-unicode-properties@10.2.2: dependencies: regenerate: 1.4.2 @@ -8918,6 +9413,32 @@ snapshots: shebang-regex@3.0.0: {} + should-equal@2.0.0: + dependencies: + should-type: 1.4.0 + + should-format@3.0.3: + dependencies: + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + + should-type-adaptors@1.1.0: + dependencies: + should-type: 1.4.0 + should-util: 1.0.1 + + should-type@1.4.0: {} + + should-util@1.0.1: {} + + should@13.2.3: + dependencies: + should-equal: 2.0.0 + should-format: 3.0.3 + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + should-util: 1.0.1 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -9083,6 +9604,51 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + swagger-schema-official@2.0.0-bab6bed: {} + + swagger-typescript-api@13.12.6(react@19.2.4): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + '@biomejs/js-api': 6.0.0(@biomejs/wasm-nodejs@2.5.2) + '@biomejs/wasm-nodejs': 2.5.2 + '@types/swagger-schema-official': 2.0.25 + c12: 3.3.4 + citty: 0.2.2 + consola: 3.4.2 + es-toolkit: 1.51.0 + eta: 3.5.0 + nanoid: 5.1.16 + openapi-types: 12.1.3 + swagger-schema-official: 2.0.0-bab6bed + swagger2openapi: 7.0.8 + type-fest: 5.8.0 + typescript: 6.0.3 + yaml: 2.9.0 + yummies: 7.20.1(react@19.2.4) + transitivePeerDependencies: + - '@biomejs/wasm-bundler' + - '@biomejs/wasm-web' + - encoding + - magicast + - mobx + - react + + swagger2openapi@7.0.8: + dependencies: + call-me-maybe: 1.0.2 + node-fetch: 2.7.0 + node-fetch-h2: 2.3.0 + node-readfiles: 0.2.0 + oas-kit-common: 1.0.8 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + oas-validator: 5.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.2 + transitivePeerDependencies: + - encoding + tagged-tag@1.0.0: {} tailwind-merge@3.6.0: {} @@ -9114,6 +9680,8 @@ snapshots: dependencies: tldts: 7.0.30 + tr46@0.0.3: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -9148,6 +9716,10 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -9200,6 +9772,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9289,6 +9863,13 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -9357,6 +9938,10 @@ snapshots: yallist@3.1.1: {} + yaml@1.10.3: {} + + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -9377,6 +9962,17 @@ snapshots: yoctocolors@2.1.2: {} + yummies@7.20.1(react@19.2.4): + dependencies: + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dayjs: 1.11.23 + dompurify: 3.4.14 + nanoid: 5.1.16 + tailwind-merge: 3.6.0 + optionalDependencies: + react: 19.2.4 + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e3684..297374d 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/scripts/check-icons.mjs b/scripts/check-icons.mjs new file mode 100644 index 0000000..ef0e156 --- /dev/null +++ b/scripts/check-icons.mjs @@ -0,0 +1,74 @@ +/* global console, process */ + +import {readFile} from 'node:fs/promises'; +import path from 'node:path'; +import { + collectGeneratedIconFileNames, + collectIconSvgEntries, + createIconIndexSource, + iconManifestFileName, + normalizeIconSvgSource, + validateSvgContent, +} from './icon-utils.mjs'; + +const projectRoot = process.cwd(); +const iconsRoot = path.join(projectRoot, 'src', 'assets', 'icons'); +const svgRoot = path.join(iconsRoot, 'svg'); +const generatedRoot = path.join(iconsRoot, 'generated'); +const indexPath = path.join(iconsRoot, 'index.ts'); +const manifestPath = path.join(generatedRoot, iconManifestFileName); +const relativePath = (filePath) => + path.relative(projectRoot, filePath).split(path.sep).join('/'); +const svgEntries = await collectIconSvgEntries(svgRoot); +const errors = []; + +const readFileOrNull = async (filePath) => { + try { + return await readFile(filePath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + + throw error; + } +}; + +for (const {filePath, source} of svgEntries) { + errors.push(...validateSvgContent(filePath, source, projectRoot)); + + if (normalizeIconSvgSource(source) !== source) { + errors.push(`${relativePath(filePath)} - run \`pnpm icons:convert\``); + } +} + +const generatedFileNames = await collectGeneratedIconFileNames(generatedRoot); +const expectedGeneratedNames = svgEntries.map( + ({componentName}) => `${componentName}.tsx` +); +const indexSource = await readFileOrNull(indexPath); +const manifestSource = await readFileOrNull(manifestPath); + +if (indexSource !== createIconIndexSource(svgEntries)) { + errors.push(`${relativePath(indexPath)} - run \`pnpm icons:generate\``); +} + +if ( + JSON.stringify(generatedFileNames) !== JSON.stringify(expectedGeneratedNames) +) { + errors.push(`${relativePath(generatedRoot)} - run \`pnpm icons:generate\``); +} + +if (manifestSource == null) { + errors.push(`${relativePath(manifestPath)} - run \`pnpm icons:generate\``); +} + +if (errors.length > 0) { + console.error('Icon SVG check failed:'); + console.error(errors.map((error) => `- ${error}`).join('\n')); + process.exitCode = 1; +} else { + console.log( + `Icon SVG validation and generated output sync passed (${svgEntries.length} files).` + ); +} diff --git a/scripts/convert-icons.mjs b/scripts/convert-icons.mjs new file mode 100644 index 0000000..8c5b8cc --- /dev/null +++ b/scripts/convert-icons.mjs @@ -0,0 +1,42 @@ +/* global console, process */ + +import {readFile, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import { + collectIconSvgEntries, + normalizeIconSvgSource, + validateSvgContent, +} from './icon-utils.mjs'; + +const projectRoot = process.cwd(); +const iconsRoot = path.join(projectRoot, 'src', 'assets', 'icons'); +const svgRoot = path.join(iconsRoot, 'svg'); +const svgEntries = await collectIconSvgEntries(svgRoot); +const errors = []; +let convertedCount = 0; + +for (const {filePath} of svgEntries) { + const source = await readFile(filePath, 'utf8'); + errors.push(...validateSvgContent(filePath, source, projectRoot)); + + if (errors.length > 0) { + continue; + } + + const normalizedSource = normalizeIconSvgSource(source); + + if (normalizedSource !== source) { + await writeFile(filePath, normalizedSource, 'utf8'); + convertedCount += 1; + } +} + +if (errors.length > 0) { + console.error('Icon SVG conversion failed:'); + console.error(errors.map((error) => `- ${error}`).join('\n')); + process.exitCode = 1; +} else { + console.log( + `Converted ${convertedCount} of ${svgEntries.length} icon SVG files.` + ); +} diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..b2da6c9 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,146 @@ +/* global console, process */ + +import {spawn} from 'node:child_process'; +import {mkdir, readFile, readdir, rm, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import { + collectIconSvgEntries, + createIconIndexSource, + iconManifestFileName, + normalizeCurrentColorAttributes, + validateSvgContent, +} from './icon-utils.mjs'; + +const projectRoot = process.cwd(); +const iconsRoot = path.join(projectRoot, 'src', 'assets', 'icons'); +const svgRoot = path.join(iconsRoot, 'svg'); +const generatedRoot = path.join(iconsRoot, 'generated'); +const indexPath = path.join(iconsRoot, 'index.ts'); +const manifestPath = path.join(generatedRoot, iconManifestFileName); + +const cleanGeneratedFiles = async () => { + await mkdir(generatedRoot, {recursive: true}); + + const entries = await readdir(generatedRoot, {withFileTypes: true}); + await Promise.all( + entries + .filter( + (entry) => + entry.isFile() && + (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) + ) + .map((entry) => rm(path.join(generatedRoot, entry.name))) + ); +}; + +const runCommand = (args) => { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd: projectRoot, + stdio: 'inherit', + }); + + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`${args[0]} exited with code ${code}`)); + }); + }); +}; + +const runSvgr = async () => { + await runCommand([ + path.join(projectRoot, 'node_modules', '@svgr', 'cli', 'bin', 'svgr'), + '--typescript', + '--icon', + '--memo', + '--ref', + '--no-index', + '--no-prettier', + '--out-dir', + path.relative(projectRoot, generatedRoot), + path.relative(projectRoot, svgRoot), + ]); +}; + +const runPrettier = async () => { + await runCommand([ + path.join(projectRoot, 'node_modules', 'prettier', 'bin', 'prettier.cjs'), + '--write', + path.relative(projectRoot, generatedRoot), + path.relative(projectRoot, indexPath), + ]); +}; + +const normalizeGeneratedIcons = async () => { + const entries = await readdir(generatedRoot, {withFileTypes: true}); + + await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.tsx')) + .map(async (entry) => { + const filePath = path.join(generatedRoot, entry.name); + const source = await readFile(filePath, 'utf8'); + const normalizedSource = normalizeCurrentColorAttributes( + source + .replace('import * as React from "react";\n', '') + .replace( + 'import type { SVGProps } from "react";\nimport { Ref, forwardRef, memo } from "react";', + "import { forwardRef, memo, type Ref, type SVGProps } from 'react';" + ) + ); + + await writeFile(filePath, normalizedSource, 'utf8'); + }) + ); +}; + +const writeIconIndex = async (svgEntries) => { + await writeFile(indexPath, createIconIndexSource(svgEntries), 'utf8'); +}; + +const writeIconManifest = async (svgEntries) => { + await writeFile( + manifestPath, + `${JSON.stringify( + svgEntries.map(({componentName, fileName}) => ({ + componentName, + generatedFileName: `${componentName}.tsx`, + sourceFileName: fileName, + })), + null, + 2 + )}\n`, + 'utf8' + ); +}; + +const svgEntries = await collectIconSvgEntries(svgRoot); +const validationErrors = svgEntries.flatMap(({filePath, source}) => + validateSvgContent(filePath, source, projectRoot) +); + +if (validationErrors.length > 0) { + console.error('Icon SVG validation failed:'); + console.error(validationErrors.map((error) => `- ${error}`).join('\n')); + process.exit(1); +} + +await cleanGeneratedFiles(); + +if (svgEntries.length > 0) { + await runSvgr(); + await normalizeGeneratedIcons(); +} + +await writeIconIndex(svgEntries); +await writeIconManifest(svgEntries); +await runPrettier(); + +console.log( + `Generated ${svgEntries.length} icon${svgEntries.length === 1 ? '' : 's'}.` +); diff --git a/scripts/icon-utils.mjs b/scripts/icon-utils.mjs new file mode 100644 index 0000000..02b57ab --- /dev/null +++ b/scripts/icon-utils.mjs @@ -0,0 +1,204 @@ +import {readFile, readdir} from 'node:fs/promises'; +import path from 'node:path'; + +export const iconFileNamePattern = /^ic-[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/; +export const iconManifestFileName = 'manifest.json'; + +export const collectSvgFiles = async (directory) => { + let entries = []; + + try { + entries = await readdir(directory, {withFileTypes: true}); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + + throw error; + } + + const files = await Promise.all( + entries.map((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return collectSvgFiles(entryPath); + } + + return entry.isFile() && entry.name.toLowerCase().endsWith('.svg') + ? [entryPath] + : []; + }) + ); + + return files.flat().sort(); +}; + +export const collectIconSvgEntries = async (directory) => { + const svgFiles = await collectSvgFiles(directory); + const entries = await Promise.all( + svgFiles.map(async (filePath) => ({ + componentName: getIconComponentName(filePath), + fileName: path.basename(filePath), + filePath, + source: await readFile(filePath, 'utf8'), + })) + ); + + return entries.sort((firstEntry, secondEntry) => + firstEntry.fileName.localeCompare(secondEntry.fileName) + ); +}; + +export const collectGeneratedIconFileNames = async (directory) => { + let entries = []; + + try { + entries = await readdir(directory, {withFileTypes: true}); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + + throw error; + } + + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.tsx')) + .map((entry) => entry.name) + .sort(); +}; + +export const getIconComponentName = (filePath) => { + const baseName = path.basename(filePath, '.svg'); + + return baseName + .split('-') + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(''); +}; + +export const createIconIndexSource = (svgEntries) => { + const exportLines = svgEntries.map( + ({componentName}) => + `export {default as ${componentName}} from './generated/${componentName}';` + ); + + return [ + '// This barrel file is auto-generated. Do not edit it manually.', + '// Run `pnpm icons:generate` to rebuild icon exports.', + ...exportLines, + '', + ].join('\n'); +}; + +const neutralIconColorValuePattern = String.raw`(?:#000(?:000)?|#191f28|#1a1e27|black|var\(\s*--(?:fill|stroke)-0\s*,\s*(?:#000(?:000)?|#191f28|#1a1e27|black)\s*\))`; +const neutralIconColorValueRegExp = new RegExp( + `^${neutralIconColorValuePattern}$`, + 'i' +); + +const normalizeCurrentColorValue = (value) => + neutralIconColorValueRegExp.test(value.trim()) ? 'currentColor' : value; + +export const normalizeCurrentColorAttributes = (source) => { + return source + .replace( + /\b(fill|stroke)\s*=\s*(["'])([^"']+)\2/gi, + (match, name, quote, value) => + `${name}=${quote}${normalizeCurrentColorValue(value)}${quote}` + ) + .replace( + /\b(fill|stroke)\s*=\s*{\s*(["'])([^"']+)\2\s*}/gi, + (match, name, quote, value) => + `${name}={${quote}${normalizeCurrentColorValue(value)}${quote}}` + ) + .replace( + /\b(fill|stroke):\s*(["'])([^"']+)\2/gi, + (match, name, quote, value) => + `${name}: ${quote}${normalizeCurrentColorValue(value)}${quote}` + ) + .replace( + /\b(fill|stroke):\s*([^;}"'\s][^;}"'\n]*)(?=[;}"'\n]|$)/gi, + (match, name, value) => `${name}: ${normalizeCurrentColorValue(value)}` + ); +}; + +export const normalizeSvgRootSizeToViewBox = (source) => { + const svgOpenTagMatch = source.match(/]*>/i); + + if (svgOpenTagMatch == null) { + return source; + } + + const svgOpenTag = svgOpenTagMatch[0]; + const viewBoxMatch = svgOpenTag.match(/\bviewBox\s*=\s*(["'])([^"']+)\1/i); + + if (viewBoxMatch == null) { + return source; + } + + const [, , viewBoxValue] = viewBoxMatch; + const [, , width, height] = viewBoxValue.trim().split(/\s+/).map(Number); + + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return source; + } + + const normalizedSvgOpenTag = svgOpenTag + .replace(/\bwidth\s*=\s*(["'])[^"']+\1/i, `width="${width}"`) + .replace(/\bheight\s*=\s*(["'])[^"']+\1/i, `height="${height}"`); + + return source.replace(svgOpenTag, normalizedSvgOpenTag); +}; + +export const normalizeIconSvgSource = (source) => { + return normalizeSvgRootSizeToViewBox(normalizeCurrentColorAttributes(source)); +}; + +export const validateSvgContent = (filePath, source, projectRoot) => { + const errors = []; + const fileName = path.basename(filePath); + const relativePath = path + .relative(projectRoot, filePath) + .split(path.sep) + .join('/'); + const blockedTagPattern = /<\s*(script|foreignObject|iframe|object|embed)\b/i; + const eventAttributePattern = /\s(on[a-z][\w:-]*)\s*=/i; + const javascriptUrlPattern = /(?:href|xlink:href)\s*=\s*["']\s*javascript:/i; + const hrefAttributePattern = /\s(?:href|xlink:href)\s*=\s*["']([^"']*)["']/gi; + + if (!iconFileNamePattern.test(fileName)) { + errors.push('file name must match ic-name.svg'); + } + + if (blockedTagPattern.test(source)) { + errors.push( + 'blocked element: script, foreignObject, iframe, object, or embed' + ); + } + + if (eventAttributePattern.test(source)) { + errors.push('blocked event attribute: on*'); + } + + if (javascriptUrlPattern.test(source)) { + errors.push('blocked javascript: URL'); + } + + for (const match of source.matchAll(hrefAttributePattern)) { + const hrefValue = match[1].trim(); + + if (hrefValue !== '' && !hrefValue.startsWith('#')) { + errors.push(`blocked external href: ${hrefValue}`); + } + } + + return errors.map((message) => `${relativePath} - ${message}`); +}; diff --git a/src/app/api/auth/_lib/auth-route.ts b/src/app/api/auth/_lib/auth-route.ts new file mode 100644 index 0000000..180c4f8 --- /dev/null +++ b/src/app/api/auth/_lib/auth-route.ts @@ -0,0 +1,81 @@ +import {cookies} from 'next/headers'; +import {NextResponse} from 'next/server'; + +import {ApiError, normalizeApiError} from '@/src/shared/api/api-error'; +import {AUTH_COOKIE_NAMES, AUTH_COOKIE_PATHS} from '@/src/shared/auth'; + +interface AuthTokenPair { + accessToken?: string; + refreshToken?: string; +} + +const AUTH_COOKIE_OPTIONS = { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', +} as const; + +const getCookieStore = () => { + return cookies(); +}; + +const createMissingTokenError = () => { + return new ApiError({ + message: '인증 토큰 응답이 올바르지 않습니다.', + status: 502, + type: 'server', + }); +}; + +export const setAuthCookies = async ({ + accessToken, + refreshToken, +}: AuthTokenPair) => { + if (!accessToken || !refreshToken) { + throw createMissingTokenError(); + } + + const cookieStore = await getCookieStore(); + + cookieStore.set(AUTH_COOKIE_NAMES.accessToken, accessToken, { + ...AUTH_COOKIE_OPTIONS, + path: AUTH_COOKIE_PATHS.accessToken, + }); + cookieStore.set(AUTH_COOKIE_NAMES.refreshToken, refreshToken, { + ...AUTH_COOKIE_OPTIONS, + path: AUTH_COOKIE_PATHS.refreshToken, + }); +}; + +export const clearAuthCookies = async () => { + const cookieStore = await getCookieStore(); + + cookieStore.set(AUTH_COOKIE_NAMES.accessToken, '', { + ...AUTH_COOKIE_OPTIONS, + maxAge: 0, + path: AUTH_COOKIE_PATHS.accessToken, + }); + cookieStore.set(AUTH_COOKIE_NAMES.refreshToken, '', { + ...AUTH_COOKIE_OPTIONS, + maxAge: 0, + path: AUTH_COOKIE_PATHS.refreshToken, + }); +}; + +export const getRefreshToken = async () => { + return (await getCookieStore()).get(AUTH_COOKIE_NAMES.refreshToken)?.value; +}; + +export const createApiErrorResponse = (error: unknown) => { + const apiError = normalizeApiError(error); + + return NextResponse.json( + { + code: apiError.code, + message: apiError.message, + }, + { + status: apiError.status ?? 500, + } + ); +}; diff --git a/src/app/api/auth/access-token/route.ts b/src/app/api/auth/access-token/route.ts new file mode 100644 index 0000000..f977278 --- /dev/null +++ b/src/app/api/auth/access-token/route.ts @@ -0,0 +1,42 @@ +import {NextResponse} from 'next/server'; + +import {API_ENDPOINTS, type AdminApiTypes} from '@/src/shared/api'; +import {ApiError} from '@/src/shared/api/api-error'; +import {createServerApi} from '@/src/shared/api/server-client'; + +import { + createApiErrorResponse, + getRefreshToken, + setAuthCookies, +} from '../_lib/auth-route'; + +const createMissingRefreshTokenError = () => { + return new ApiError({ + message: '리프레시 토큰이 없습니다.', + status: 401, + type: 'auth', + }); +}; + +export async function POST() { + try { + const refreshToken = await getRefreshToken(); + + if (!refreshToken) { + throw createMissingRefreshTokenError(); + } + + const serverApi = await createServerApi({accessToken: refreshToken}); + const {data} = await serverApi.post( + API_ENDPOINTS.auth.accessToken, + undefined, + {maxRedirects: 0} + ); + + await setAuthCookies(data); + + return new NextResponse(null, {status: 204}); + } catch (error) { + return createApiErrorResponse(error); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..3bc4d03 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,63 @@ +import {NextResponse} from 'next/server'; + +import {API_ENDPOINTS, type AdminApiTypes} from '@/src/shared/api'; +import {createServerApi} from '@/src/shared/api/server-client'; + +import {createApiErrorResponse, setAuthCookies} from '../_lib/auth-route'; + +const createBadRequestResponse = (message: string) => { + return NextResponse.json({message}, {status: 400}); +}; + +const isLoginRequest = ( + value: unknown +): value is AdminApiTypes.PostLoginRequest => { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).id === 'string' && + typeof (value as Record).password === 'string' + ); +}; + +const parseLoginRequest = async (request: Request) => { + let body: unknown; + + try { + body = await request.json(); + } catch { + return createBadRequestResponse('Malformed JSON request body.'); + } + + if (!isLoginRequest(body)) { + return createBadRequestResponse('id and password must be strings.'); + } + + return body; +}; + +export async function POST(request: Request) { + const body = await parseLoginRequest(request); + + if (body instanceof Response) { + return body; + } + + try { + const serverApi = await createServerApi({includeAccessToken: false}); + const {data} = await serverApi.post( + API_ENDPOINTS.auth.login, + body, + {maxRedirects: 0} + ); + + await setAuthCookies(data); + + return NextResponse.json({ + adminId: data.adminId, + }); + } catch (error) { + return createApiErrorResponse(error); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..ab6d218 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +import {NextResponse} from 'next/server'; + +import {clearAuthCookies} from '../_lib/auth-route'; + +export async function POST() { + await clearAuthCookies(); + + return new NextResponse(null, {status: 204}); +} diff --git a/src/app/dashboard/DashboardHeader.tsx b/src/app/dashboard/DashboardHeader.tsx new file mode 100644 index 0000000..e8fd4b4 --- /dev/null +++ b/src/app/dashboard/DashboardHeader.tsx @@ -0,0 +1,17 @@ +'use client'; + +import {useRouter} from 'next/navigation'; + +import {Header} from '@/src/shared/components/layout/Header'; + +function DashboardHeader() { + const router = useRouter(); + + const handleLogout = () => { + router.push('/login'); + }; + + return
; +} + +export {DashboardHeader}; diff --git a/src/app/dashboard/history/page.tsx b/src/app/dashboard/history/page.tsx new file mode 100644 index 0000000..28c3862 --- /dev/null +++ b/src/app/dashboard/history/page.tsx @@ -0,0 +1,5 @@ +import {HistoryManagementPage} from '@/src/features/history-management/HistoryManagementPage'; + +export default function HistoryPage() { + return ; +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..ff7f9f5 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,20 @@ +import type {ReactNode} from 'react'; + +import {DashboardHeader} from '@/src/app/dashboard/DashboardHeader'; +import {SidebarNavigation} from '@/src/shared/components/layout/SidebarNavigation'; + +export default function DashboardLayout({children}: {children: ReactNode}) { + return ( +
+ + +
+ + +
{children}
+
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..133b43f --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,5 @@ +import {StoreManagementPage} from '@/src/features/store-management/StoreManagementPage'; + +export default function DashboardPage() { + return ; +} diff --git a/src/app/dashboard/reviews/page.tsx b/src/app/dashboard/reviews/page.tsx new file mode 100644 index 0000000..7f328c2 --- /dev/null +++ b/src/app/dashboard/reviews/page.tsx @@ -0,0 +1,5 @@ +import {ReviewManagementPage} from '@/src/features/review-management/ReviewManagementPage'; + +export default function ReviewsPage() { + return ; +} diff --git a/src/app/dashboard/themes/page.tsx b/src/app/dashboard/themes/page.tsx new file mode 100644 index 0000000..717a0ca --- /dev/null +++ b/src/app/dashboard/themes/page.tsx @@ -0,0 +1,5 @@ +import {ThemeManagementPage} from '@/src/features/theme-management/ThemeManagementPage'; + +export default function ThemesPage() { + return ; +} diff --git a/src/app/fonts/PretendardVariable.woff2 b/src/app/fonts/PretendardVariable.woff2 new file mode 100644 index 0000000..49c54b5 Binary files /dev/null and b/src/app/fonts/PretendardVariable.woff2 differ diff --git a/src/app/globals.css b/src/app/globals.css index e69de29..97a0728 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -0,0 +1,38 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; +@import '../shared/styles/tokens.css'; + +@layer base { + * { + border-color: var(--border); + } + + html { + font-family: var(--font-sans); + } + + body { + min-height: 100vh; + margin: 0; + background: var(--background); + color: var(--foreground); + -webkit-font-smoothing: antialiased; + font-family: var(--font-family-pretendard); + } + + button, + input, + select, + textarea { + font: inherit; + } + + button:not(:disabled):not([aria-disabled='true']), + [role='button']:not([aria-disabled='true']), + [role='tab']:not([aria-disabled='true']), + a[href]:not([aria-disabled='true']), + summary:not([aria-disabled='true']), + label[for]:not([aria-disabled='true']) { + cursor: pointer; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e73d35e..dc55813 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,20 @@ +import type {Metadata} from 'next'; +import localFont from 'next/font/local'; + import '@/src/app/globals.css'; +import {QueryProvider} from '@/src/shared/query'; + +const pretendard = localFont({ + src: './fonts/PretendardVariable.woff2', + display: 'swap', + variable: '--font-pretendard', + weight: '100 900', +}); + +export const metadata: Metadata = { + title: 'Roominus Admin', + description: 'Roominus Admin Service', +}; export default function RootLayout({ children, @@ -6,8 +22,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + {children} + ); -} \ No newline at end of file +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..d54d3e5 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,60 @@ +import LoginImage from '@/src/assets/images/login.svg'; +import {LoginForm} from '@/src/features/auth/components/LoginForm'; + +export default function LoginPage() { + const isProduction = process.env.NODE_ENV === 'production'; + const testAdminId = process.env.NEXT_PUBLIC_TEST_ADMIN_ID; + const testAdminPassword = process.env.NEXT_PUBLIC_TEST_ADMIN_PASSWORD; + const shouldShowTestAccount = + !isProduction && + process.env.NEXT_PUBLIC_SHOW_TEST_ACCOUNT === 'true' && + Boolean(testAdminId && testAdminPassword); + + return ( +
+
+
+ + +
+

+ {'루미너스 어드민'} +

+

+ {'관리자 로그인'} +

+
+
+ +
+ + + {shouldShowTestAccount ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 94f7929..fb4570b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,5 @@ -export default function Home() { - return ( -
- Home -
- ); +import {redirect} from 'next/navigation'; + +export default function HomePage() { + redirect('/login'); } diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx new file mode 100644 index 0000000..71a8004 --- /dev/null +++ b/src/app/users/page.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link'; + +const users = [ + {id: 1, name: '사용자 1'}, + {id: 2, name: '사용자 2'}, +]; + +export default function UsersPage() { + return ( +
+

Users

+ +
    + {users.map((user) => ( +
  • + {user.name} +
  • + ))} +
+
+ ); +} diff --git a/src/assets/icons/generated/IcChevronDown.tsx b/src/assets/icons/generated/IcChevronDown.tsx new file mode 100644 index 0000000..054cc14 --- /dev/null +++ b/src/assets/icons/generated/IcChevronDown.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcChevronDown = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcChevronDown); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcEye.tsx b/src/assets/icons/generated/IcEye.tsx new file mode 100644 index 0000000..fc1d553 --- /dev/null +++ b/src/assets/icons/generated/IcEye.tsx @@ -0,0 +1,29 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcEye = (props: SVGProps, ref: Ref) => ( + + + + +); +const ForwardRef = forwardRef(SvgIcEye); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcHistory.tsx b/src/assets/icons/generated/IcHistory.tsx new file mode 100644 index 0000000..82e25dc --- /dev/null +++ b/src/assets/icons/generated/IcHistory.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcHistory = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcHistory); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcKeyRound.tsx b/src/assets/icons/generated/IcKeyRound.tsx new file mode 100644 index 0000000..279e116 --- /dev/null +++ b/src/assets/icons/generated/IcKeyRound.tsx @@ -0,0 +1,32 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcKeyRound = ( + props: SVGProps, + ref: Ref +) => ( + + + + +); +const ForwardRef = forwardRef(SvgIcKeyRound); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcLayers.tsx b/src/assets/icons/generated/IcLayers.tsx new file mode 100644 index 0000000..6212d1f --- /dev/null +++ b/src/assets/icons/generated/IcLayers.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcLayers = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcLayers); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcLogOut.tsx b/src/assets/icons/generated/IcLogOut.tsx new file mode 100644 index 0000000..cf5df10 --- /dev/null +++ b/src/assets/icons/generated/IcLogOut.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcLogOut = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcLogOut); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcMessageSquare.tsx b/src/assets/icons/generated/IcMessageSquare.tsx new file mode 100644 index 0000000..ee8320a --- /dev/null +++ b/src/assets/icons/generated/IcMessageSquare.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcMessageSquare = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcMessageSquare); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcPlus.tsx b/src/assets/icons/generated/IcPlus.tsx new file mode 100644 index 0000000..82554b7 --- /dev/null +++ b/src/assets/icons/generated/IcPlus.tsx @@ -0,0 +1,22 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcPlus = (props: SVGProps, ref: Ref) => ( + + + +); +const ForwardRef = forwardRef(SvgIcPlus); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcRefreshCw.tsx b/src/assets/icons/generated/IcRefreshCw.tsx new file mode 100644 index 0000000..a23178b --- /dev/null +++ b/src/assets/icons/generated/IcRefreshCw.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcRefreshCw = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcRefreshCw); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcStar.tsx b/src/assets/icons/generated/IcStar.tsx new file mode 100644 index 0000000..5fa7bef --- /dev/null +++ b/src/assets/icons/generated/IcStar.tsx @@ -0,0 +1,22 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcStar = (props: SVGProps, ref: Ref) => ( + + + +); +const ForwardRef = forwardRef(SvgIcStar); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcStore.tsx b/src/assets/icons/generated/IcStore.tsx new file mode 100644 index 0000000..4ad391b --- /dev/null +++ b/src/assets/icons/generated/IcStore.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcStore = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcStore); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/IcTriangleAlert.tsx b/src/assets/icons/generated/IcTriangleAlert.tsx new file mode 100644 index 0000000..52e8633 --- /dev/null +++ b/src/assets/icons/generated/IcTriangleAlert.tsx @@ -0,0 +1,25 @@ +import {forwardRef, memo, type Ref, type SVGProps} from 'react'; +const SvgIcTriangleAlert = ( + props: SVGProps, + ref: Ref +) => ( + + + +); +const ForwardRef = forwardRef(SvgIcTriangleAlert); +const Memo = memo(ForwardRef); +export default Memo; diff --git a/src/assets/icons/generated/manifest.json b/src/assets/icons/generated/manifest.json new file mode 100644 index 0000000..507bfb2 --- /dev/null +++ b/src/assets/icons/generated/manifest.json @@ -0,0 +1,62 @@ +[ + { + "componentName": "IcChevronDown", + "generatedFileName": "IcChevronDown.tsx", + "sourceFileName": "ic-chevron-down.svg" + }, + { + "componentName": "IcEye", + "generatedFileName": "IcEye.tsx", + "sourceFileName": "ic-eye.svg" + }, + { + "componentName": "IcHistory", + "generatedFileName": "IcHistory.tsx", + "sourceFileName": "ic-history.svg" + }, + { + "componentName": "IcKeyRound", + "generatedFileName": "IcKeyRound.tsx", + "sourceFileName": "ic-key-round.svg" + }, + { + "componentName": "IcLayers", + "generatedFileName": "IcLayers.tsx", + "sourceFileName": "ic-layers.svg" + }, + { + "componentName": "IcLogOut", + "generatedFileName": "IcLogOut.tsx", + "sourceFileName": "ic-log-out.svg" + }, + { + "componentName": "IcMessageSquare", + "generatedFileName": "IcMessageSquare.tsx", + "sourceFileName": "ic-message-square.svg" + }, + { + "componentName": "IcPlus", + "generatedFileName": "IcPlus.tsx", + "sourceFileName": "ic-plus.svg" + }, + { + "componentName": "IcRefreshCw", + "generatedFileName": "IcRefreshCw.tsx", + "sourceFileName": "ic-refresh-cw.svg" + }, + { + "componentName": "IcStar", + "generatedFileName": "IcStar.tsx", + "sourceFileName": "ic-star.svg" + }, + { + "componentName": "IcStore", + "generatedFileName": "IcStore.tsx", + "sourceFileName": "ic-store.svg" + }, + { + "componentName": "IcTriangleAlert", + "generatedFileName": "IcTriangleAlert.tsx", + "sourceFileName": "ic-triangle-alert.svg" + } +] diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts new file mode 100644 index 0000000..978edae --- /dev/null +++ b/src/assets/icons/index.ts @@ -0,0 +1,14 @@ +// This barrel file is auto-generated. Do not edit it manually. +// Run `pnpm icons:generate` to rebuild icon exports. +export {default as IcChevronDown} from './generated/IcChevronDown'; +export {default as IcEye} from './generated/IcEye'; +export {default as IcHistory} from './generated/IcHistory'; +export {default as IcKeyRound} from './generated/IcKeyRound'; +export {default as IcLayers} from './generated/IcLayers'; +export {default as IcLogOut} from './generated/IcLogOut'; +export {default as IcMessageSquare} from './generated/IcMessageSquare'; +export {default as IcPlus} from './generated/IcPlus'; +export {default as IcRefreshCw} from './generated/IcRefreshCw'; +export {default as IcStar} from './generated/IcStar'; +export {default as IcStore} from './generated/IcStore'; +export {default as IcTriangleAlert} from './generated/IcTriangleAlert'; diff --git a/src/assets/icons/svg/ic-chevron-down.svg b/src/assets/icons/svg/ic-chevron-down.svg new file mode 100644 index 0000000..f87d176 --- /dev/null +++ b/src/assets/icons/svg/ic-chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-eye.svg b/src/assets/icons/svg/ic-eye.svg new file mode 100644 index 0000000..59d4c3f --- /dev/null +++ b/src/assets/icons/svg/ic-eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/svg/ic-history.svg b/src/assets/icons/svg/ic-history.svg new file mode 100644 index 0000000..29a275b --- /dev/null +++ b/src/assets/icons/svg/ic-history.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-key-round.svg b/src/assets/icons/svg/ic-key-round.svg new file mode 100644 index 0000000..5e53b9e --- /dev/null +++ b/src/assets/icons/svg/ic-key-round.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/svg/ic-layers.svg b/src/assets/icons/svg/ic-layers.svg new file mode 100644 index 0000000..b5029f8 --- /dev/null +++ b/src/assets/icons/svg/ic-layers.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-log-out.svg b/src/assets/icons/svg/ic-log-out.svg new file mode 100644 index 0000000..7bc5e92 --- /dev/null +++ b/src/assets/icons/svg/ic-log-out.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-message-square.svg b/src/assets/icons/svg/ic-message-square.svg new file mode 100644 index 0000000..4c9f36b --- /dev/null +++ b/src/assets/icons/svg/ic-message-square.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-plus.svg b/src/assets/icons/svg/ic-plus.svg new file mode 100644 index 0000000..d1100e2 --- /dev/null +++ b/src/assets/icons/svg/ic-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-refresh-cw.svg b/src/assets/icons/svg/ic-refresh-cw.svg new file mode 100644 index 0000000..948c33c --- /dev/null +++ b/src/assets/icons/svg/ic-refresh-cw.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-star.svg b/src/assets/icons/svg/ic-star.svg new file mode 100644 index 0000000..2ad8103 --- /dev/null +++ b/src/assets/icons/svg/ic-star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-store.svg b/src/assets/icons/svg/ic-store.svg new file mode 100644 index 0000000..e3583c0 --- /dev/null +++ b/src/assets/icons/svg/ic-store.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/svg/ic-triangle-alert.svg b/src/assets/icons/svg/ic-triangle-alert.svg new file mode 100644 index 0000000..11739fa --- /dev/null +++ b/src/assets/icons/svg/ic-triangle-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/login.svg b/src/assets/images/login.svg new file mode 100644 index 0000000..a8712ce --- /dev/null +++ b/src/assets/images/login.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx deleted file mode 100644 index 6138844..0000000 --- a/src/components/ui/button.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" - - return ( - - ) -} - -export { Button, buttonVariants } diff --git a/public/fonts/.gitkeep b/src/features/.gitkeep similarity index 100% rename from public/fonts/.gitkeep rename to src/features/.gitkeep diff --git a/src/features/auth/api/login-api.ts b/src/features/auth/api/login-api.ts new file mode 100644 index 0000000..1609605 --- /dev/null +++ b/src/features/auth/api/login-api.ts @@ -0,0 +1,18 @@ +import { + API_ENDPOINTS, + getBrowserApi, + type AdminApiTypes, +} from '@/src/shared/api'; + +type LoginResult = Pick; + +export const loginAdmin = async ( + credentials: AdminApiTypes.PostLoginRequest +) => { + const {data} = await getBrowserApi().post( + API_ENDPOINTS.auth.login, + credentials + ); + + return data; +}; diff --git a/src/features/auth/components/LoginForm.tsx b/src/features/auth/components/LoginForm.tsx new file mode 100644 index 0000000..326590e --- /dev/null +++ b/src/features/auth/components/LoginForm.tsx @@ -0,0 +1,130 @@ +'use client'; + +import {useRef, useState, type FormEvent} from 'react'; +import {useRouter} from 'next/navigation'; + +import {isApiError} from '@/src/shared/api'; +import {Input} from '@/src/shared/components/ui/Input'; + +import {loginAdmin} from '../api/login-api'; + +const LOGIN_ERROR_MESSAGE = '로그인 요청을 처리하지 못했습니다.'; + +interface LoginFormProps { + idPlaceholder?: string; + passwordPlaceholder?: string; +} + +function LoginForm({idPlaceholder, passwordPlaceholder}: LoginFormProps) { + const router = useRouter(); + const isSubmittingRef = useRef(false); + const [idError, setIdError] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [submitError, setSubmitError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (isSubmittingRef.current) { + return; + } + + const formData = new FormData(event.currentTarget); + const id = String(formData.get('id') ?? '').trim(); + const password = String(formData.get('password') ?? ''); + const nextIdError = id ? '' : '아이디를 입력해 주세요.'; + const nextPasswordError = password ? '' : '비밀번호를 입력해 주세요.'; + + setIdError(nextIdError); + setPasswordError(nextPasswordError); + setSubmitError(''); + + if (nextIdError || nextPasswordError) { + return; + } + + isSubmittingRef.current = true; + setIsSubmitting(true); + + try { + await loginAdmin({id, password}); + router.replace('/dashboard'); + router.refresh(); + } catch (error) { + setSubmitError(isApiError(error) ? error.message : LOGIN_ERROR_MESSAGE); + } finally { + isSubmittingRef.current = false; + setIsSubmitting(false); + } + }; + + return ( +
+
+ + + {idError ? ( +

+ {idError} +

+ ) : null} +
+ +
+ + + {passwordError ? ( +

+ {passwordError} +

+ ) : null} +
+ + {submitError ? ( +

+ {submitError} +

+ ) : null} + + +
+ ); +} + +export {LoginForm}; diff --git a/src/features/history-management/HistoryManagementPage.tsx b/src/features/history-management/HistoryManagementPage.tsx new file mode 100644 index 0000000..3931972 --- /dev/null +++ b/src/features/history-management/HistoryManagementPage.tsx @@ -0,0 +1,18 @@ +import {PageTitle} from '@/src/shared/components/layout/PageTitle'; + +import {HistoryManagementTable} from './components/HistoryManagementTable'; + +function HistoryManagementPage() { + return ( +
+ 히스토리} + subtitle='데이터 수정 내역 조회' + /> + + +
+ ); +} + +export {HistoryManagementPage}; diff --git a/src/features/history-management/components/HistoryManagementTable.tsx b/src/features/history-management/components/HistoryManagementTable.tsx new file mode 100644 index 0000000..5184b4f --- /dev/null +++ b/src/features/history-management/components/HistoryManagementTable.tsx @@ -0,0 +1,171 @@ +'use client'; + +import * as React from 'react'; +import {IcLayers, IcStore} from '@/src/assets/icons'; +import {Input} from '@/src/shared/components/ui/Input'; +import {PageSizeSelect} from '@/src/shared/components/ui/PageSizeSelect'; +import {Tabs, type TabItem} from '@/src/shared/components/ui/Tabs'; +import {cn} from '@/src/shared/lib/utils'; + +import type { + HistoryAction, + HistoryRecord, + HistoryTarget, +} from '../model/history'; +import {histories} from '../model/mockHistories'; + +const targetLabels = { + store: '매장', + theme: '테마', +} satisfies Record; + +const actionLabels = { + create: '생성', + update: '수정', +} satisfies Record; + +const pageSizeOptions = [ + {value: '5', label: '5'}, + {value: '10', label: '10'}, + {value: '20', label: '20'}, +]; + +function HistoryManagementTable() { + const [activeTarget, setActiveTarget] = + React.useState('store'); + const [pageSize, setPageSize] = React.useState('10'); + const rows = histories.filter((history) => history.target === activeTarget); + const visibleRows = rows.slice(0, Number(pageSize)); + + function handleTargetChange(value: string) { + setActiveTarget(value as HistoryTarget); + setPageSize(value === 'theme' ? '5' : '10'); + } + + return ( +
+ + +
+ + + + +
+ +
+
+ + + + + 항목 + + + 작업 + + + 수정자 + + 변경 시간 + + + + {visibleRows.map((history) => ( + + ))} + +
+
+
+ +

+ 총 {rows.length}개의 히스토리 +

+
+ ); +} + +function createHistoryTabs(): TabItem[] { + return (['store', 'theme'] satisfies HistoryTarget[]).map((target) => ({ + value: target, + label: targetLabels[target], + count: histories.filter((history) => history.target === target).length, + icon: target === 'store' ? IcStore : IcLayers, + iconSize: 'sm', + })); +} + +function HistoryDateInput({id, label}: {id: string; label: string}) { + return ( +
+ + +
+ ); +} + +function HistoryHeaderCell({className, children}: React.ComponentProps<'th'>) { + return ( + + {children} + + ); +} + +function HistoryTableRow({history}: {history: HistoryRecord}) { + return ( + + + {history.item} + + + + + + {history.editor} + + + {history.changedAt} + + + ); +} + +function HistoryActionTag({action}: {action: HistoryAction}) { + const isCreate = action === 'create'; + + return ( + + {actionLabels[action]} + + ); +} + +export {HistoryManagementTable}; diff --git a/src/features/history-management/model/history.ts b/src/features/history-management/model/history.ts new file mode 100644 index 0000000..f8df00c --- /dev/null +++ b/src/features/history-management/model/history.ts @@ -0,0 +1,14 @@ +type HistoryTarget = 'store' | 'theme'; + +type HistoryAction = 'create' | 'update'; + +type HistoryRecord = { + id: number; + target: HistoryTarget; + item: string; + action: HistoryAction; + editor: string; + changedAt: string; +}; + +export type {HistoryAction, HistoryRecord, HistoryTarget}; diff --git a/src/features/history-management/model/mockHistories.ts b/src/features/history-management/model/mockHistories.ts new file mode 100644 index 0000000..0d484be --- /dev/null +++ b/src/features/history-management/model/mockHistories.ts @@ -0,0 +1,78 @@ +import type {HistoryRecord} from './history'; + +const histories: HistoryRecord[] = [ + { + id: 1, + target: 'store', + item: '코드케이 강남점', + action: 'create', + editor: '박어드민', + changedAt: '2026. 3. 16. 오전 10:00:00', + }, + { + id: 2, + target: 'store', + item: '키이스케이프 LOG_IN 1', + action: 'update', + editor: '이운영', + changedAt: '2026. 3. 14. 오후 3:30:00', + }, + { + id: 3, + target: 'store', + item: '비트포비아 홍대점', + action: 'create', + editor: '관리자', + changedAt: '2026. 3. 11. 오전 10:30:00', + }, + { + id: 4, + target: 'store', + item: '키이스케이프 LOG_IN 1', + action: 'create', + editor: '관리자', + changedAt: '2026. 3. 10. 오전 9:00:00', + }, + { + id: 5, + target: 'theme', + item: '셜록의 서재', + action: 'create', + editor: '최매니저', + changedAt: '2026. 3. 18. 오전 11:10:00', + }, + { + id: 6, + target: 'theme', + item: '저주받은 인형', + action: 'create', + editor: '관리자', + changedAt: '2026. 3. 17. 오후 1:20:00', + }, + { + id: 7, + target: 'theme', + item: '미드나잇 익스프레스', + action: 'create', + editor: '관리자', + changedAt: '2026. 3. 15. 오전 9:45:00', + }, + { + id: 8, + target: 'theme', + item: '크리쳐 - 신인류의 탄생', + action: 'update', + editor: '관리자', + changedAt: '2026. 3. 13. 오전 11:15:00', + }, + { + id: 9, + target: 'theme', + item: '크리쳐 - 신인류의 탄생', + action: 'create', + editor: '관리자', + changedAt: '2026. 3. 12. 오후 2:20:00', + }, +]; + +export {histories}; diff --git a/src/features/review-management/ReviewManagementPage.tsx b/src/features/review-management/ReviewManagementPage.tsx new file mode 100644 index 0000000..c402e35 --- /dev/null +++ b/src/features/review-management/ReviewManagementPage.tsx @@ -0,0 +1,21 @@ +import {PageTitle} from '@/src/shared/components/layout/PageTitle'; + +import {ReviewManagementTabs} from './components/ReviewManagementTabs'; +import {reviews} from './model/mockReviews'; + +function ReviewManagementPage() { + return ( +
+ 후기 관리} + subtitle='후기 확인 및 관리' + /> + + +
+ ); +} + +export {ReviewManagementPage}; diff --git a/src/features/review-management/components/ReviewManagementTable.tsx b/src/features/review-management/components/ReviewManagementTable.tsx new file mode 100644 index 0000000..fac91c5 --- /dev/null +++ b/src/features/review-management/components/ReviewManagementTable.tsx @@ -0,0 +1,146 @@ +import {Star, Trash2, TriangleAlert} from 'lucide-react'; + +import {Button} from '@/src/shared/components/ui/button'; +import {cn} from '@/src/shared/lib/utils'; + +import type {Review} from '../model/review'; + +type ReviewManagementTableProps = { + reviews: Review[]; + onDelete: (reviewId: number) => void; +}; + +const columnHeaders = [ + 'ID', + '테마', + '작성자', + '평점', + '내용', + '작성일', + '상태', + '작업', +]; + +function ReviewManagementTable({ + reviews, + onDelete, +}: ReviewManagementTableProps) { + return ( +
+
+ + + + {columnHeaders.map((header) => ( + + ))} + + + + {reviews.length > 0 ? ( + reviews.map((review) => ( + + + + + + + + + + + )) + ) : ( + + + + )} + +
+ {header} +
+ {review.id} + + {review.theme} + + {review.author} + + + + {review.content} + + {review.createdAt} + + {review.status === 'reported' ? : null} + + +
+ 데이터가 없습니다. +
+
+
+ ); +} + +function ReviewRating({rating}: {rating: number}) { + return ( +
+ {Array.from({length: 5}, (_, index) => { + const isFilled = index < rating; + + return ( +
+ ); +} + +function ReportedBadge() { + return ( + + + ); +} + +export {ReviewManagementTable}; diff --git a/src/features/review-management/components/ReviewManagementTabs.tsx b/src/features/review-management/components/ReviewManagementTabs.tsx new file mode 100644 index 0000000..5e88803 --- /dev/null +++ b/src/features/review-management/components/ReviewManagementTabs.tsx @@ -0,0 +1,123 @@ +'use client'; + +import * as React from 'react'; + +import {cn} from '@/src/shared/lib/utils'; + +import type {Review, ReviewTab} from '../model/review'; +import {ReviewManagementTable} from './ReviewManagementTable'; + +type ReviewManagementTabsProps = { + reviews: Review[]; +}; + +type ReviewTabItem = { + value: ReviewTab; + label: string; +}; + +const reviewTabs: ReviewTabItem[] = [ + { + value: 'reported', + label: '신고된 후기', + }, + { + value: 'all', + label: '전체 후기', + }, + { + value: 'deleted', + label: '삭제된 후기', + }, +]; + +function ReviewManagementTabs({reviews}: ReviewManagementTabsProps) { + const [reviewItems, setReviewItems] = React.useState(reviews); + const [activeTab, setActiveTab] = React.useState('reported'); + const reportedCount = reviewItems.filter( + (review) => review.status === 'reported' + ).length; + const rows = getRowsByTab(reviewItems, activeTab); + const summaryLabel = getSummaryLabel(activeTab, rows.length); + + function handleDelete(reviewId: number) { + setReviewItems((currentReviews) => + currentReviews.map((review) => + review.id === reviewId ? {...review, status: 'deleted'} : review + ) + ); + } + + return ( +
+
+ {reviewTabs.map((tab) => { + const isActive = tab.value === activeTab; + + return ( + + ); + })} +
+ +
+ +

+ {summaryLabel} +

+
+
+ ); +} + +function getRowsByTab(reviews: Review[], tab: ReviewTab) { + if (tab === 'reported') { + return reviews.filter((review) => review.status === 'reported'); + } + + if (tab === 'deleted') { + return reviews.filter((review) => review.status === 'deleted'); + } + + return reviews.filter((review) => review.status !== 'deleted'); +} + +function getSummaryLabel(tab: ReviewTab, count: number) { + if (tab === 'reported') { + return `총 ${count}개의 신고된 후기`; + } + + if (tab === 'deleted') { + return `총 ${count}개의 삭제된 후기`; + } + + return `총 ${count}개의 후기`; +} + +export {ReviewManagementTabs}; diff --git a/src/features/review-management/model/mockReviews.ts b/src/features/review-management/model/mockReviews.ts new file mode 100644 index 0000000..647c5a7 --- /dev/null +++ b/src/features/review-management/model/mockReviews.ts @@ -0,0 +1,42 @@ +import type {Review} from './review'; + +const reviews: Review[] = [ + { + id: 1, + theme: '크리쳐 - 신인류의 탄생', + author: '김철수', + rating: 5, + content: '정말 재미있었어요! 스토리도 좋고 퍼즐도 적절했습니다.', + createdAt: '2026. 3. 15.', + status: 'normal', + }, + { + id: 2, + theme: '크리쳐 - 신인류의 탄생', + author: '이영희', + rating: 1, + content: '욕설 및 부적절한 내용', + createdAt: '2026. 3. 16.', + status: 'reported', + }, + { + id: 3, + theme: '미드나잇 익스프레스', + author: '박지성', + rating: 4, + content: '분위기가 정말 좋았어요. 난이도는 적당했습니다.', + createdAt: '2026. 3. 17.', + status: 'normal', + }, + { + id: 4, + theme: '저주받은 인형', + author: '최민수', + rating: 5, + content: '공포 테마 좋아하시면 강추! 진짜 무서워요', + createdAt: '2026. 3. 18.', + status: 'normal', + }, +]; + +export {reviews}; diff --git a/src/features/review-management/model/review.ts b/src/features/review-management/model/review.ts new file mode 100644 index 0000000..3550881 --- /dev/null +++ b/src/features/review-management/model/review.ts @@ -0,0 +1,15 @@ +type ReviewStatus = 'normal' | 'reported' | 'deleted'; + +type Review = { + id: number; + theme: string; + author: string; + rating: number; + content: string; + createdAt: string; + status: ReviewStatus; +}; + +type ReviewTab = 'reported' | 'all' | 'deleted'; + +export type {Review, ReviewStatus, ReviewTab}; diff --git a/src/features/store-management/StoreManagementPage.tsx b/src/features/store-management/StoreManagementPage.tsx new file mode 100644 index 0000000..e214f02 --- /dev/null +++ b/src/features/store-management/StoreManagementPage.tsx @@ -0,0 +1,21 @@ +import {PageTitle} from '@/src/shared/components/layout/PageTitle'; + +import {StoreAddDialogTrigger} from './components/StoreAddDialogTrigger'; +import {StoreManagementTable} from './components/StoreManagementTable'; +import {stores} from './model/mockStores'; + +function StoreManagementPage() { + return ( +
+ 매장 관리} + subtitle={`총 ${stores.length}개의 매장`} + action={} + /> + + +
+ ); +} + +export {StoreManagementPage}; diff --git a/src/features/store-management/components/StoreAddDialogTrigger.tsx b/src/features/store-management/components/StoreAddDialogTrigger.tsx new file mode 100644 index 0000000..77adb76 --- /dev/null +++ b/src/features/store-management/components/StoreAddDialogTrigger.tsx @@ -0,0 +1,100 @@ +'use client'; + +import {useId, useState} from 'react'; + +import {PageTitleActionButton} from '@/src/shared/components/layout/PageTitle'; + +import { + StoreFormDialog, + StoreFormDialogField, + type StoreFormFieldConfig, +} from './StoreFormDialog'; + +const basicFields: StoreFormFieldConfig[] = [ + {id: 'name', label: '매장명', required: true}, + {id: 'address', label: '주소', required: true}, + {id: 'websiteUrl', label: '웹사이트 URL', type: 'url'}, + {id: 'reservationUrl', label: '예약 URL', type: 'url'}, + {id: 'phone', label: '연락처', type: 'tel'}, +]; + +const operationDateFields: StoreFormFieldConfig[] = [ + {id: 'openedAt', label: '오픈일', type: 'date'}, + {id: 'expectedClosedAt', label: '폐업 예정일', type: 'date'}, + {id: 'renovationStartedAt', label: '리뉴얼 시작일', type: 'date'}, + {id: 'renovationEndedAt', label: '리뉴얼 종료일', type: 'date'}, + {id: 'closedAt', label: '폐업일', type: 'date'}, +]; + +function StoreAddDialogTrigger() { + const [isOpen, setIsOpen] = useState(false); + const titleId = useId(); + const descriptionId = useId(); + + const closeDialog = () => { + setIsOpen(false); + }; + + return ( + <> + setIsOpen(true)}> + 매장 추가 + + + {isOpen ? ( + +
+ {basicFields.slice(0, 2).map((field) => ( + + ))} + + + + {basicFields.slice(2).map((field) => ( + + ))} +
+ +
+ +
+

+ 운영 날짜 정보 +

+ +
+ {operationDateFields.map((field) => ( + + ))} +
+
+ + ) : null} + + ); +} + +export {StoreAddDialogTrigger}; diff --git a/src/features/store-management/components/StoreEditDialogTrigger.tsx b/src/features/store-management/components/StoreEditDialogTrigger.tsx new file mode 100644 index 0000000..7d086df --- /dev/null +++ b/src/features/store-management/components/StoreEditDialogTrigger.tsx @@ -0,0 +1,156 @@ +'use client'; + +import {useId, useState} from 'react'; +import {MapPin, Pencil} from 'lucide-react'; + +import {Button} from '@/src/shared/components/ui/button'; + +import type {Store} from '../model/store'; +import { + StoreFormDialog, + StoreFormDialogField, + type StoreFormFieldConfig, +} from './StoreFormDialog'; + +const operationDateFields = [ + {id: 'openedAt', label: '오픈일', type: 'date'}, + {id: 'expectedClosedAt', label: '폐업 예정일', type: 'date'}, + {id: 'renovationStartedAt', label: '리뉴얼 시작일', type: 'date'}, + {id: 'renovationEndedAt', label: '리뉴얼 종료일', type: 'date'}, + {id: 'closedAt', label: '폐업일', type: 'date'}, +] satisfies StoreFormFieldConfig[]; + +function StoreEditDialogTrigger({store}: {store: Store}) { + const [isOpen, setIsOpen] = useState(false); + const titleId = useId(); + const descriptionId = useId(); + const idPrefix = `store-edit-${store.id}`; + + const basicFields = [ + {id: 'name', label: '매장명', required: true, defaultValue: store.name}, + { + id: 'address', + label: '주소', + required: true, + defaultValue: store.address, + helperText: ( + <> +