From 5356bd1e6a11b63cc252a77295c192019e98b351 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 27 Jul 2026 15:04:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-character):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B8=EC=A6=9D=20=EC=85=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + DESIGN.md | 196 +++ components.json | 21 + login-surface.png | Bin 0 -> 17288 bytes package-lock.json | 1201 ++++++++++++++++- package.json | 8 +- protected-shell-surface.png | Bin 0 -> 69534 bytes src/app/App.test.tsx | 284 +++- src/app/App.tsx | 258 +++- src/app/admin-pages.tsx | 37 + src/app/browser-location.ts | 33 + src/app/route-paths.ts | 7 + src/features/auth/api/auth-api.ts | 31 + .../auth/model/auth-session-context.ts | 32 + .../auth/model/auth-session-storage.test.ts | 35 + .../auth/model/auth-session-storage.ts | 38 + src/features/auth/model/auth-session.tsx | 77 ++ src/features/auth/pages/LoginPage.tsx | 156 +++ src/features/auth/schemas/login-schema.ts | 8 + src/features/auth/tests/auth-api.test.ts | 107 ++ src/features/auth/tests/auth-session.test.tsx | 203 +++ src/features/auth/tests/login-page.test.tsx | 89 ++ src/main.tsx | 7 +- src/shared/api/__tests__/client-auth.test.ts | 215 +++ .../api/__tests__/client-test-helpers.ts | 31 + src/shared/api/__tests__/client.test.ts | 117 ++ src/shared/api/__tests__/pagination.test.ts | 66 + src/shared/api/__tests__/query-client.test.ts | 58 + src/shared/api/api-error.ts | 21 + src/shared/api/client.ts | 131 ++ src/shared/api/pagination.ts | 24 + src/shared/api/query-client.ts | 22 + src/shared/api/types.ts | 34 + src/shared/lib/__tests__/formatters.test.ts | 12 + src/shared/lib/crop-image.test.ts | 103 ++ src/shared/lib/crop-image.ts | 101 ++ src/shared/lib/formatters.ts | 23 + src/shared/test/server.ts | 3 + src/shared/test/setup-isolation.test.ts | 11 + src/shared/test/setup.ts | 15 +- .../ui/__tests__/admin-audio-player.test.tsx | 117 ++ .../confirm-deactivate-dialog.test.tsx | 60 + src/shared/ui/__tests__/file-field.test.tsx | 41 + .../file-media-dependency-boundary.test.ts | 28 + .../ui/__tests__/icon-only-action.test.tsx | 21 + .../ui/__tests__/image-crop-dialog.test.tsx | 68 + src/shared/ui/__tests__/page-state.test.tsx | 29 + .../ui/__tests__/resource-pagination.test.tsx | 42 + .../responsive-resource-list.test.tsx | 18 + .../ui/__tests__/search-toolbar.test.tsx | 42 + src/shared/ui/__tests__/status-badge.test.tsx | 35 + .../__tests__/unsaved-changes-guard.test.tsx | 48 + .../ui/__tests__/upload-progress.test.tsx | 25 + src/shared/ui/admin-audio-player.tsx | 141 ++ src/shared/ui/audio-playback-context.ts | 9 + src/shared/ui/audio-playback-provider.tsx | 22 + src/shared/ui/confirm-deactivate-dialog.tsx | 38 + src/shared/ui/file-field.tsx | 48 + src/shared/ui/icon-only-action.tsx | 24 + src/shared/ui/image-crop-dialog.tsx | 153 +++ src/shared/ui/page-state.tsx | 66 + src/shared/ui/resource-pagination.tsx | 32 + src/shared/ui/responsive-resource-list.tsx | 16 + src/shared/ui/search-toolbar.tsx | 37 + src/shared/ui/status-badge.tsx | 91 ++ src/shared/ui/unsaved-changes-guard.tsx | 68 + src/shared/ui/upload-progress.tsx | 39 + src/shared/ui/use-audio-playback.ts | 19 + src/shared/ui/use-modal-focus.ts | 50 + src/shared/validation/audio-file-policy.ts | 31 + .../validation/file-media-policy.test.ts | 93 ++ src/shared/validation/file-validation.ts | 31 + src/shared/validation/image-policy.ts | 15 + src/styles/__tests__/design-system.test.ts | 165 +++ src/styles/globals.css | 289 ++++ tests/e2e/accessibility-shell.spec.ts | 77 ++ tests/e2e/auth.spec.ts | 43 + tests/e2e/smoke.spec.ts | 2 +- tsconfig.app.json | 10 +- tsconfig.json | 3 +- tsconfig.test.json | 27 + vite.config.ts | 6 +- 82 files changed, 6017 insertions(+), 18 deletions(-) create mode 100644 DESIGN.md create mode 100644 components.json create mode 100644 login-surface.png create mode 100644 protected-shell-surface.png create mode 100644 src/app/admin-pages.tsx create mode 100644 src/app/browser-location.ts create mode 100644 src/app/route-paths.ts create mode 100644 src/features/auth/api/auth-api.ts create mode 100644 src/features/auth/model/auth-session-context.ts create mode 100644 src/features/auth/model/auth-session-storage.test.ts create mode 100644 src/features/auth/model/auth-session-storage.ts create mode 100644 src/features/auth/model/auth-session.tsx create mode 100644 src/features/auth/pages/LoginPage.tsx create mode 100644 src/features/auth/schemas/login-schema.ts create mode 100644 src/features/auth/tests/auth-api.test.ts create mode 100644 src/features/auth/tests/auth-session.test.tsx create mode 100644 src/features/auth/tests/login-page.test.tsx create mode 100644 src/shared/api/__tests__/client-auth.test.ts create mode 100644 src/shared/api/__tests__/client-test-helpers.ts create mode 100644 src/shared/api/__tests__/client.test.ts create mode 100644 src/shared/api/__tests__/pagination.test.ts create mode 100644 src/shared/api/__tests__/query-client.test.ts create mode 100644 src/shared/api/api-error.ts create mode 100644 src/shared/api/client.ts create mode 100644 src/shared/api/pagination.ts create mode 100644 src/shared/api/query-client.ts create mode 100644 src/shared/api/types.ts create mode 100644 src/shared/lib/__tests__/formatters.test.ts create mode 100644 src/shared/lib/crop-image.test.ts create mode 100644 src/shared/lib/crop-image.ts create mode 100644 src/shared/lib/formatters.ts create mode 100644 src/shared/test/server.ts create mode 100644 src/shared/test/setup-isolation.test.ts create mode 100644 src/shared/ui/__tests__/admin-audio-player.test.tsx create mode 100644 src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx create mode 100644 src/shared/ui/__tests__/file-field.test.tsx create mode 100644 src/shared/ui/__tests__/file-media-dependency-boundary.test.ts create mode 100644 src/shared/ui/__tests__/icon-only-action.test.tsx create mode 100644 src/shared/ui/__tests__/image-crop-dialog.test.tsx create mode 100644 src/shared/ui/__tests__/page-state.test.tsx create mode 100644 src/shared/ui/__tests__/resource-pagination.test.tsx create mode 100644 src/shared/ui/__tests__/responsive-resource-list.test.tsx create mode 100644 src/shared/ui/__tests__/search-toolbar.test.tsx create mode 100644 src/shared/ui/__tests__/status-badge.test.tsx create mode 100644 src/shared/ui/__tests__/unsaved-changes-guard.test.tsx create mode 100644 src/shared/ui/__tests__/upload-progress.test.tsx create mode 100644 src/shared/ui/admin-audio-player.tsx create mode 100644 src/shared/ui/audio-playback-context.ts create mode 100644 src/shared/ui/audio-playback-provider.tsx create mode 100644 src/shared/ui/confirm-deactivate-dialog.tsx create mode 100644 src/shared/ui/file-field.tsx create mode 100644 src/shared/ui/icon-only-action.tsx create mode 100644 src/shared/ui/image-crop-dialog.tsx create mode 100644 src/shared/ui/page-state.tsx create mode 100644 src/shared/ui/resource-pagination.tsx create mode 100644 src/shared/ui/responsive-resource-list.tsx create mode 100644 src/shared/ui/search-toolbar.tsx create mode 100644 src/shared/ui/status-badge.tsx create mode 100644 src/shared/ui/unsaved-changes-guard.tsx create mode 100644 src/shared/ui/upload-progress.tsx create mode 100644 src/shared/ui/use-audio-playback.ts create mode 100644 src/shared/ui/use-modal-focus.ts create mode 100644 src/shared/validation/audio-file-policy.ts create mode 100644 src/shared/validation/file-media-policy.test.ts create mode 100644 src/shared/validation/file-validation.ts create mode 100644 src/shared/validation/image-policy.ts create mode 100644 src/styles/__tests__/design-system.test.ts create mode 100644 src/styles/globals.css create mode 100644 tests/e2e/accessibility-shell.spec.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tsconfig.test.json diff --git a/.gitignore b/.gitignore index 8eb29f2..a70dc92 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ mise.toml node_modules/ dist/ playwright-report/ +.playwright-mcp/ test-results/ coverage/ *.tsbuildinfo diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..9ec3d00 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,196 @@ +# AI Character Admin Design System + +## 0. Research Log + +- PRD baseline: adopted `10.1~10.9` as the visual contract because Task 1.1 is PRD-driven setup, not a new visual exploration. +- ui-ux-pro-max: ran `.opencode/skills/ui-ux-pro-max/scripts/search.py` with the PRD `10.9` design-system query. Adopted dense operational dashboard, subtle motion, status colors, focus visibility, reduced-motion, and no emoji icons. Excluded generated green palette, white on primary, dark mode support, Fira remote fonts, oversized landing typography, glass/continuous animation, and GSAP page transitions because they conflict with PRD `10.1~10.3`. +- ui-ux-pro-max UX: ran `.opencode/skills/ui-ux-pro-max/scripts/search.py "animation accessibility z-index loading" --domain ux -n 12`. Adopted loading feedback for waits over 300ms, semantic z-index scale, reduced motion, 150-300ms micro-interactions, and no decorative infinite animation. Deferred lazy-loaded media, loading buttons, skeletons, and route-level loading because Task 1.1 has no async page, media, form submit, or router surface yet. +- Existing UI: only the Phase 0 root shell exists, so this document defines the minimal system before new UI consumes it. + +## 1. Atmosphere & Identity + +A bright Korean operations console: dense, calm, and explicit. The signature is cyan as an operational signal, used for primary action, links, focus, and information states while surfaces stay quiet and readable. + +## 2. Color + +### Primitive Tokens + +| Role | Token | Light | Usage | +|---|---|---:|---| +| Brand 50 | `--color-brand-50` | `#F0FBFF` | Faint emphasis background | +| Brand 100 | `--color-brand-100` | `#D9F6FF` | Selected row, accent surface | +| Brand 200 | `--color-brand-200` | `#B5EEFF` | Emphasis border | +| Brand 300 | `--color-brand-300` | `#7CE2FF` | Decorative low emphasis | +| Brand 400 | `--color-brand-400` | `#36D1FF` | Secondary accent | +| Brand 500 | `--color-brand-500` | `#00BDF7` | Fixed main color and primary background | +| Brand 600 | `--color-brand-600` | `#00A9DE` | Primary hover | +| Brand 700 | `--color-brand-700` | `#009DCE` | Primary active | +| Brand 800 | `--color-brand-800` | `#007EA8` | Link, focus ring, info | +| Brand 900 | `--color-brand-900` | `#086789` | Link hover | +| Brand 950 | `--color-brand-950` | `#063747` | Deep brand accent | + +### Semantic Tokens + +| Role | Token | Light | Usage | +|---|---|---:|---| +| Background | `--background` | `#F6FBFD` | Page background | +| Foreground | `--foreground` | `#102A33` | Main text | +| Card / Popover | `--card`, `--popover` | `#FFFFFF` | Surfaces and overlays | +| Muted | `--muted` | `#E9F4F7` | Muted surface | +| Muted foreground | `--muted-foreground` | `#425F69` | Secondary text | +| Secondary | `--secondary` | `#E1F5FA` | Secondary controls | +| Secondary foreground | `--secondary-foreground` | `#123E4B` | Text on secondary | +| Accent | `--accent` | `#D9F6FF` | Hover and selected surface | +| Accent foreground | `--accent-foreground` | `#0C566F` | Text on accent | +| Border | `--border` | `#D5E8EE` | Decorative separators | +| Input | `--input` | `#577581` | Required control boundary | +| Primary | `--primary` | `#00BDF7` | Main CTA | +| Primary foreground | `--primary-foreground` | `#062B36` | Text/icons on primary | +| Ring / Link | `--ring`, `--link` | `#007EA8` | Focus and link | +| Info | `--info` | `#086789` | Small informational labels | +| Success | `--success` | `#167347` | Open/success state | +| Success surface | `--success-surface` | `#EAF8F0` | Success Badge surface | +| Warning | `--warning` | `#9A5B00` | Scheduled/warning state | +| Warning surface | `--warning-surface` | `#FFF7E6` | Warning Badge surface | +| Destructive | `--destructive` | `#B42318` | Error/destructive state | +| Inactive | `--inactive` | `#52636A` | Inactive state | +| Inactive surface | `--inactive-surface` | `#EEF3F5` | Inactive Badge surface | + +### Rules + +- Feature components use semantic or component tokens, not raw hex. +- `#00BDF7` never uses white foreground; `--primary-foreground` is `#062B36`. +- Light theme only in this release. No `.dark`, theme provider, theme toggle, or system dark integration. + +## 3. Typography + +| Level | Size | Weight | Line Height | Usage | +|---|---:|---:|---:|---| +| Page title | `24px` | 700 | 1.3 | Main page title | +| Section title | `20px` | 650 | 1.4 | Section headers | +| Body | `14px` | 400 | 1.5 | Dense admin text and tables | +| Small | `13px` | 400 | 1.45 | Secondary metadata | +| Caption | `12px` | 600 | 1.4 | Labels and badges | +| Mobile input | `16px` | 400 | 1.5 | iOS-safe input text | + +Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system-ui`, `sans-serif`. + +## 4. Spacing & Layout + +- Base spacing is an 8px grid, with 4px available only for tight icon-label gaps. +- Control target minimum is 44px. +- Initial shell remains simple: the document scrolls until Task 1.4 introduces the admin shell. +- Future desktop shell dimensions follow PRD: 240px sidebar and 56px header. + +## 5. Components + +### StatusBadge + +- Structure: inline status container with a decorative dot and visible Korean text label. +- Variants: `OPEN`, `SCHEDULED`, `INACTIVE`. +- Spacing: `--space-1`, `--space-2`. +- States: static display only in Task 1.1. +- Accessibility: `aria-label="상태: {label}"`; color never carries status alone. +- Motion: none. + +### IconOnlyAction + +- Structure: button with icon slot, accessible name, and tooltip description. +- Variants: single icon-only control primitive. +- Spacing: 44px minimum target, centered icon. +- States: hover, active, focus-visible, disabled. +- Accessibility: `aria-label`, tooltip element, 3:1 control boundary and focus ring tokens. Tooltip text equal to the label is not wired as `aria-describedby` to avoid duplicate name/description. +- Motion: 150ms transform/color transition, disabled under reduced motion. + +### PageState + +- Structure: tokenized card surface for loading, empty, error, and content pass-through states. +- Variants: loading and empty use `role="status"`; error uses `role="alert"` and optional retry button. +- Accessibility: state meaning is visible Korean copy plus semantic role; retry is a native button. +- Motion: none. + +### SearchToolbar + +- Structure: controlled search input with optional filter slot inside a bordered card surface. +- Variants: generic query only; domain endpoint query names stay outside the primitive. +- Accessibility: search input has a visible label and mobile-safe input sizing. +- Motion: none. + +### ResourcePagination + +- Structure: `PageData` summary, page-size select, and native previous/next buttons. +- Variants: disabled previous/next derive from page bounds and `hasNext`. +- Accessibility: native controls expose Korean names and preserve keyboard behavior. +- Motion: none beyond control state color. + +### ResponsiveResourceList + +- Structure: desktop and mobile rendering slots inside one region; the primitive owns breakpoint visibility only. +- Variants: none; domain columns, DTOs, and action unions stay in consuming screens. +- Accessibility: region is named by the caller. +- Motion: none. + +### ConfirmDeactivateDialog + +- Structure: modal confirmation dialog with target name, impact copy, cancel, and destructive confirm action. +- Variants: confirmation only; never a switch replacement. +- Accessibility: `alertdialog`, visible title, native buttons. +- Motion: none. + +### UnsavedChangesGuard + +- Structure: render-prop guard for route-leave triggers that opens a confirmation dialog only while dirty. +- Variants: dirty blocks, safe/saved state lets the route callback run immediately. +- Accessibility: cancel keeps the user in context and returns focus to the trigger. +- Motion: none. + +### FileField + +- Structure: controlled `File | null` field with visible label, native file input, accept guidance, selected filename, and clear button. +- Variants: domain-neutral only; allowed extensions, MIME, and max bytes are injected by caller policy. +- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; clear is a native button. +- Motion: none. + +### ImageCropDialog + +- Structure: modal crop surface with preview, output size, directional move buttons, zoom range, reset, cancel, and apply. +- Variants: caller injects `aspect`, `maxWidth`, and `noUpscale`; domain profile names and GIF exceptions stay outside the primitive. +- Accessibility: dialog has visible title, keyboard preview controls, range input, and button alternatives. No pointer-only requirement in Phase 1.6. +- Motion: transform-only preview adjustment. No crop dependency is added; Canvas is used only when generating the final `File`. + +### UploadProgress + +- Structure: status label, optional filename, progressbar, and optional cancel/retry buttons. +- Variants: display-only upload state; no upload client, request adapter, or domain form ownership. +- Accessibility: progressbar exposes `aria-valuenow`; cancel/retry are native buttons. +- Motion: none. + +### AdminAudioPlayer + +- Structure: native audio element wrapped with play/pause, seek, time, volume, speed, generic error, and manual retry controls. +- Variants: shared signed-URL player only; it never downloads, autoplays, auto-refetches, or infers signed URL expiry. +- Accessibility: player region is named by title, keyboard Space/Enter toggles play, seek/volume use range inputs, speed uses native select. +- Motion: none. + +### AudioPlaybackProvider + +- Structure: shared context coordinates active audio by player id so only one player continues at a time. +- Variants: stores only player ids in React state; signed URLs are not logged, persisted, or passed into the provider. +- Accessibility: no direct rendered surface. +- Motion: none. + +## 6. Motion & Interaction + +- Motion is limited to 150ms micro-interactions for real control state changes. +- Only `transform`, `opacity`, and color changes are used for Task 1.1 primitives. +- `prefers-reduced-motion: reduce` disables non-essential transitions and animations. + +## 7. Depth & Surface + +Strategy: mixed but restrained. Dense admin surfaces primarily use borders and tonal shifts; shadows are reserved for overlays in later phases. + +## 8. Accessibility Constraints & Accepted Debt + +- Target WCAG 2.2 AA: body text 4.5:1, control boundary/focus indicator 3:1, visible focus on every interactive element. +- Korean text must keep system fallbacks and inputs must stay at least 16px on mobile. +- Accepted debt: no component showcase route in Task 1.1 because the requested scope is token/base/component setup only; RTL tests exercise the primitive states. diff --git a/components.json b/components.json new file mode 100644 index 0000000..6cac087 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/shared/ui", + "utils": "@/shared/lib/utils", + "ui": "@/shared/ui", + "lib": "@/shared/lib", + "hooks": "@/shared/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/login-surface.png b/login-surface.png new file mode 100644 index 0000000000000000000000000000000000000000..d7282b40b396d15c494cda5e6cb0f83f6589fe67 GIT binary patch literal 17288 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 Uow^)0KfdU!2kdN literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index 91c7743..dec4a1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,16 @@ "name": "ai-character-admin-web", "version": "0.0.0", "dependencies": { + "@tanstack/react-query": "^5.101.4", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "zod": "^4.4.3" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "10.0.1", "@playwright/test": "1.61.1", + "@tailwindcss/vite": "4.3.3", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.0", "@testing-library/react": "16.3.2", @@ -26,6 +30,8 @@ "eslint-plugin-react-refresh": "0.5.3", "globals": "17.7.0", "jsdom": "29.1.1", + "msw": "^2.15.0", + "tailwindcss": "4.3.3", "typescript": "6.0.3", "typescript-eslint": "8.65.0", "vite": "8.1.5", @@ -90,6 +96,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -739,6 +758,93 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -789,6 +895,31 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -808,6 +939,31 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.139.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", @@ -1105,6 +1261,565 @@ "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1270,6 +1985,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -1735,6 +2467,16 @@ "node": ">=12" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1846,6 +2588,51 @@ "node": ">=18" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1853,6 +2640,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1976,6 +2777,27 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2245,6 +3067,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2339,6 +3188,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2365,6 +3224,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2435,6 +3322,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2448,6 +3345,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2462,6 +3366,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2938,6 +3852,61 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -3006,6 +3975,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3071,6 +4047,13 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3236,6 +4219,16 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3246,6 +4239,13 @@ "node": ">=0.10.0" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -3309,6 +4309,13 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3339,6 +4346,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3356,6 +4376,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -3363,6 +4393,41 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3383,6 +4448,40 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3507,6 +4606,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3562,6 +4677,16 @@ "dev": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3877,6 +5002,40 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -3894,6 +5053,16 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3901,6 +5070,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3918,7 +5116,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 727a3a2..4156241 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,16 @@ "e2e": "playwright test" }, "dependencies": { + "@tanstack/react-query": "^5.101.4", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "zod": "^4.4.3" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "10.0.1", "@playwright/test": "1.61.1", + "@tailwindcss/vite": "4.3.3", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.0", "@testing-library/react": "16.3.2", @@ -33,6 +37,8 @@ "eslint-plugin-react-refresh": "0.5.3", "globals": "17.7.0", "jsdom": "29.1.1", + "msw": "^2.15.0", + "tailwindcss": "4.3.3", "typescript": "6.0.3", "typescript-eslint": "8.65.0", "vite": "8.1.5", diff --git a/protected-shell-surface.png b/protected-shell-surface.png new file mode 100644 index 0000000000000000000000000000000000000000..bcf5c94fe46e72cb3cb9feffb48ea38d0475b98a GIT binary patch literal 69534 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 ZbUq?FBgCCNlqf_I8yK+Dzl_XD`3)zQ@GJlT literal 0 HcmV?d00001 diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index cd8b153..170691c 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -1,10 +1,288 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { App } from "./App"; +import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; +import { server } from "@/shared/test/server"; -test("renders Korean root shell with a main landmark", () => { +const apiBaseUrl = "https://api.example.com"; + +function saveAdminSession() { + authSessionStorage.save({ token: "admin-token", role: "ADMIN" }); +} + +function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (request: Request) => void = () => undefined) { + server.use( + http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => { + onRequest(request); + if (status === 401) { + return HttpResponse.json( + { success: false, message: "인증 정보가 없습니다.", data: null, errorProperty: null }, + { status }, + ); + } + + if (status === 403) { + return HttpResponse.json( + { success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null }, + { status }, + ); + } + + return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null }); + }), + ); +} + +beforeEach(() => { + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + window.history.replaceState({}, "", "/"); +}); + +test("preserves the Korean document language", () => { render(); expect(document.documentElement).toHaveAttribute("lang", "ko"); - expect(screen.getByRole("main")).toHaveTextContent("AI 캐릭터 관리자"); + expect(screen.getByRole("main")).toContainElement(screen.getByRole("heading", { name: "관리자 로그인" })); +}); + +test("redirects an unauthenticated direct visit to /ai-characters without exposing protected content", async () => { + window.history.pushState({}, "", "/ai-characters"); + + render(); + + expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument(); + await waitFor(() => expect(window.location.pathname).toBe("/login")); + expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument(); + expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument(); +}); + +test("renders the existing login page at /login", () => { + window.history.pushState({}, "", "/login"); + + render(); + + expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument(); +}); + +test("navigates to /ai-characters after a successful login", async () => { + window.history.pushState({}, "", "/login"); + useAiCharactersResponse(); + server.use( + http.post(`${apiBaseUrl}/admin/member/login`, () => + HttpResponse.json({ + success: true, + message: null, + data: { token: "jwt-token", role: "ADMIN" }, + errorProperty: null, + }), + ), + ); + + render(); + + fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } }); + fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } }); + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + + await waitFor(() => expect(window.location.pathname).toBe("/ai-characters")); + expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument(); +}); + +test("renders the protected admin shell for an existing ADMIN session", async () => { + saveAdminSession(); + const requests: Request[] = []; + useAiCharactersResponse(200, (request) => requests.push(request)); + window.history.pushState({}, "", "/ai-characters"); + + render(); + + expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument(); + expect(requests[0]?.url).toContain("size=20"); + expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main"); + expect(screen.getByRole("banner")).toBeInTheDocument(); + expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터"); + expect(screen.getByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).toBeInTheDocument(); + expect(screen.queryByText("루나")).not.toBeInTheDocument(); +}); + +test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => { + saveAdminSession(); + useAiCharactersResponse(); + window.history.pushState({}, "", "/ai-characters"); + + render(); + + expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Phase 2에서 AI 캐릭터 목록이 연결됩니다."); + expect(screen.queryByText("루나")).not.toBeInTheDocument(); +}); + +test("clears the session and routes to login when the protected route request returns 401", async () => { + saveAdminSession(); + useAiCharactersResponse(401); + window.history.pushState({}, "", "/ai-characters"); + + render(); + + await waitFor(() => expect(window.location.pathname).toBe("/login")); + expect(authSessionStorage.read()).toBeNull(); + expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요."); + expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument(); +}); + +test("routes to access denied without clearing the session when the protected route request returns 403", async () => { + saveAdminSession(); + useAiCharactersResponse(403); + window.history.pushState({}, "", "/ai-characters"); + + render(); + + await waitFor(() => expect(window.location.pathname).toBe("/access-denied")); + expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" }); + expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument(); + expect(screen.queryByText("루나")).not.toBeInTheDocument(); +}); + +test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => { + saveAdminSession(); + window.history.pushState({}, "", "/ai-characters"); + let resolveDenyProbeReady: (denyProbe: () => void) => void = () => undefined; + const denyProbeReady = new Promise<() => void>((resolve) => { + resolveDenyProbeReady = resolve; + }); + server.use( + http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => + new Promise((resolve) => { + resolveDenyProbeReady(() => + resolve( + HttpResponse.json( + { success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null }, + { status: 403 }, + ), + ), + ); + }), + ), + ); + + render(); + + const triggerDenyProbe = await denyProbeReady; + expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); + expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument(); + triggerDenyProbe(); + + await waitFor(() => expect(window.location.pathname).toBe("/access-denied")); + expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument(); +}); + +test("keeps keyboard focus inside the mobile menu and returns focus to the trigger", async () => { + saveAdminSession(); + useAiCharactersResponse(); + window.history.pushState({}, "", "/ai-characters"); + + render(); + + await screen.findByRole("heading", { name: "AI 캐릭터" }); + const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" }); + fireEvent.click(menuButton); + const menu = screen.getByRole("navigation", { name: "모바일 주 메뉴" }); + const closeButton = screen.getByRole("button", { name: "모바일 메뉴 닫기" }); + await waitFor(() => expect(closeButton).toHaveFocus()); + + fireEvent.keyDown(menu, { key: "Tab", shiftKey: true }); + expect(screen.getByRole("link", { name: "AI 캐릭터" })).toHaveFocus(); + + fireEvent.keyDown(menu, { key: "Tab" }); + expect(closeButton).toHaveFocus(); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument(); + expect(menuButton).toHaveFocus(); +}); + +test("shows a login warning when server logout confirmation fails", async () => { + saveAdminSession(); + useAiCharactersResponse(); + window.history.pushState({}, "", "/ai-characters"); + server.use( + http.post(`${apiBaseUrl}/member/logout`, () => + HttpResponse.json( + { success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null }, + { status: 500 }, + ), + ), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "로그아웃" })); + + await waitFor(() => expect(window.location.pathname).toBe("/login")); + expect(authSessionStorage.read()).toBeNull(); + expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다."); +}); + +test("logs out from the protected shell and routes to login", async () => { + saveAdminSession(); + useAiCharactersResponse(); + window.history.pushState({}, "", "/ai-characters"); + server.use( + http.post(`${apiBaseUrl}/member/logout`, ({ request }) => { + expect(request.headers.get("Authorization")).toBe("Bearer admin-token"); + + return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null }); + }), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "로그아웃" })); + + await waitFor(() => expect(window.location.pathname).toBe("/login")); + expect(authSessionStorage.read()).toBeNull(); +}); + +test("sends only one logout request while the first logout is in flight", async () => { + saveAdminSession(); + useAiCharactersResponse(); + window.history.pushState({}, "", "/ai-characters"); + let logoutCount = 0; + let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined; + const logoutReady = new Promise<() => void>((resolve) => { + resolveLogoutReady = resolve; + }); + server.use( + http.post(`${apiBaseUrl}/member/logout`, () => { + logoutCount += 1; + + return new Promise((resolve) => { + resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null }))); + }); + }), + ); + + render(); + + const logoutButton = await screen.findByRole("button", { name: "로그아웃" }); + fireEvent.click(logoutButton); + fireEvent.click(logoutButton); + await waitFor(() => expect(logoutCount).toBe(1)); + const finishLogout = await logoutReady; + finishLogout(); + + await waitForElementToBeRemoved(logoutButton); + expect(window.location.pathname).toBe("/login"); }); diff --git a/src/app/App.tsx b/src/app/App.tsx index b6617aa..d99af47 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,13 +1,263 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { z } from "zod"; + +import { LoginPage } from "@/features/auth/pages/LoginPage"; +import { AuthSessionProvider } from "@/features/auth/model/auth-session"; +import { useAuthSession } from "@/features/auth/model/auth-session-context"; +import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; +import { AccessDeniedPage, AiCharactersPage } from "@/app/admin-pages"; +import { routePaths } from "@/app/route-paths"; +import { navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location"; +import { AccessDeniedError } from "@/shared/api/api-error"; +import { createApiClient } from "@/shared/api/client"; +const aiCharactersRouteResponseSchema = z.unknown(); +const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요."; +const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"; + +function NavLink() { + return ( + { + event.preventDefault(); + navigateTo(routePaths.aiCharacters); + }} + > + AI 캐릭터 + + ); +} + +function ProtectedAdminShell({ routeError }: { readonly routeError: string | null }) { + const auth = useAuthSession(); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const menuButtonRef = useRef(null); + const closeButtonRef = useRef(null); + const mobileMenuRef = useRef(null); + const shouldRestoreMenuFocusRef = useRef(false); + + useEffect(() => { + if (isMobileMenuOpen || !shouldRestoreMenuFocusRef.current) { + return; + } + + shouldRestoreMenuFocusRef.current = false; + menuButtonRef.current?.focus(); + }, [isMobileMenuOpen]); + + useEffect(() => { + if (!isMobileMenuOpen) { + return undefined; + } + + closeButtonRef.current?.focus(); + + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + shouldRestoreMenuFocusRef.current = true; + setIsMobileMenuOpen(false); + } + } + + window.addEventListener("keydown", closeOnEscape); + + return () => window.removeEventListener("keydown", closeOnEscape); + }, [isMobileMenuOpen]); + + function keepFocusInMobileMenu(event: React.KeyboardEvent) { + if (event.key !== "Tab") { + return; + } + + const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll(focusableSelector) ?? []); + const firstElement = focusableElements[0]; + const lastElement = focusableElements.at(-1); + + if (firstElement === undefined || lastElement === undefined) { + return; + } + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + return; + } + + if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + + function closeMobileMenu() { + shouldRestoreMenuFocusRef.current = true; + setIsMobileMenuOpen(false); + } + + return ( +
+
+ + 본문으로 건너뛰기 + + +
+
+
+ + +
+ +
+
+ +
+
+
+ {isMobileMenuOpen ? ( +
+ +
+ ) : null} +
+ ); +} + +function AppShell() { + const auth = useAuthSession(); + const protectedRouteApiClient = useMemo( + () => + createApiClient({ + getToken: () => authSessionStorage.read()?.token ?? null, + clearSession: () => auth.clearSession(sessionExpiredNotice), + onAuthExpired: () => replaceWith(routePaths.login), + }), + [auth], + ); + const location = useBrowserLocation(); + const [routeError, setRouteError] = useState(null); + const [verifiedProtectedRouteToken, setVerifiedProtectedRouteToken] = useState(null); + + useEffect(() => { + if (location !== routePaths.login && auth.session === null) { + replaceWith(routePaths.login); + } + }, [auth.session, location]); + + useEffect(() => { + if (location !== routePaths.aiCharacters || auth.session === null) { + return undefined; + } + + let isCurrent = true; + const sessionToken = auth.session.token; + void protectedRouteApiClient + .request({ + path: "/api/v2/admin/ai-characters?page=0&size=20", + responseSchema: aiCharactersRouteResponseSchema, + authentication: "required", + }) + .then(() => { + if (isCurrent) { + setRouteError(null); + setVerifiedProtectedRouteToken(sessionToken); + } + }) + .catch((error: unknown) => { + if (!isCurrent) { + return; + } + + if (error instanceof AccessDeniedError) { + replaceWith(routePaths.accessDenied); + return; + } + + setRouteError("보호 route 확인에 실패했습니다."); + }); + + return () => { + isCurrent = false; + }; + }, [auth.session, location, protectedRouteApiClient]); + + if (location === routePaths.login) { + return ( + { + await auth.login(credentials); + navigateTo(routePaths.aiCharacters); + }} + /> + ); + } + + if (auth.session === null) { + return null; + } + + if (location === routePaths.accessDenied) { + return ; + } + + if (location === routePaths.aiCharacters && verifiedProtectedRouteToken !== auth.session.token) { + return null; + } + + return ; +} export function App() { + const apiClient = useMemo( + () => + createApiClient({ + getToken: () => authSessionStorage.read()?.token ?? null, + clearSession: authSessionStorage.remove, + onAuthExpired: () => replaceWith(routePaths.login), + }), + [], + ); + useEffect(() => { document.documentElement.lang = "ko"; }, []); return ( -
-

AI 캐릭터 관리자

-
+ + + ); } diff --git a/src/app/admin-pages.tsx b/src/app/admin-pages.tsx new file mode 100644 index 0000000..1500b8f --- /dev/null +++ b/src/app/admin-pages.tsx @@ -0,0 +1,37 @@ +import { routePaths } from "@/app/route-paths"; +import { PageState } from "@/shared/ui/page-state"; + +export function AccessDeniedPage() { + return ( +
+
+

ACCESS DENIED

+

접근 권한이 없습니다

+

ADMIN 권한이 확인되지 않아 요청한 화면을 열 수 없습니다.

+ + 로그인으로 이동 + +
+
+ ); +} + +export function AiCharactersPage({ routeError }: { readonly routeError: string | null }) { + return ( +
+
+

AI CHARACTER ADMIN

+

+ AI 캐릭터 +

+

캐릭터 목록과 생성 흐름은 Phase 2에서 연결합니다.

+
+ {routeError === null ? null : ( +

+ {routeError} +

+ )} + +
+ ); +} diff --git a/src/app/browser-location.ts b/src/app/browser-location.ts new file mode 100644 index 0000000..35e2275 --- /dev/null +++ b/src/app/browser-location.ts @@ -0,0 +1,33 @@ +import { useSyncExternalStore } from "react"; + +import { routePaths, type RoutePath } from "@/app/route-paths"; + +function subscribe(onStoreChange: () => void): () => void { + window.addEventListener("popstate", onStoreChange); + + return () => window.removeEventListener("popstate", onStoreChange); +} + +function getSnapshot(): RoutePath { + const path = window.location.pathname; + + if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.accessDenied) { + return path; + } + + return routePaths.aiCharacters; +} + +export function useBrowserLocation(): RoutePath { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function navigateTo(path: RoutePath): void { + window.history.pushState({}, "", path); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function replaceWith(path: RoutePath): void { + window.history.replaceState({}, "", path); + window.dispatchEvent(new PopStateEvent("popstate")); +} diff --git a/src/app/route-paths.ts b/src/app/route-paths.ts new file mode 100644 index 0000000..cd9e54a --- /dev/null +++ b/src/app/route-paths.ts @@ -0,0 +1,7 @@ +export const routePaths = { + accessDenied: "/access-denied", + login: "/login", + aiCharacters: "/ai-characters", +} as const; + +export type RoutePath = (typeof routePaths)[keyof typeof routePaths]; diff --git a/src/features/auth/api/auth-api.ts b/src/features/auth/api/auth-api.ts new file mode 100644 index 0000000..7260b70 --- /dev/null +++ b/src/features/auth/api/auth-api.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage"; +import type { LoginCredentials } from "@/features/auth/schemas/login-schema"; +import type { ApiClient } from "@/shared/api/client"; + +const loginResponseSchema = z.object({ + token: z.string().min(1), + role: z.literal("ADMIN"), +}); +const logoutResponseSchema = z.object({}); + +export function login(apiClient: ApiClient, credentials: LoginCredentials): Promise { + return apiClient.request({ + path: "/admin/member/login", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: credentials.email, password: credentials.password }), + responseSchema: loginResponseSchema, + authentication: "none", + }); +} + +export async function logout(apiClient: ApiClient): Promise { + await apiClient.request({ + path: "/member/logout", + method: "POST", + responseSchema: logoutResponseSchema, + authentication: "required", + }); +} diff --git a/src/features/auth/model/auth-session-context.ts b/src/features/auth/model/auth-session-context.ts new file mode 100644 index 0000000..8f0bc9b --- /dev/null +++ b/src/features/auth/model/auth-session-context.ts @@ -0,0 +1,32 @@ +import { createContext, useContext } from "react"; + +import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage"; +import type { LoginCredentials } from "@/features/auth/schemas/login-schema"; + +class MissingAuthSessionProviderError extends Error { + override readonly name = "MissingAuthSessionProviderError"; + + constructor() { + super("AuthSessionProvider is required."); + } +} + +export type AuthSessionContextValue = { + readonly session: AuthSessionRecord | null; + readonly loginNotice: string | null; + readonly login: (credentials: LoginCredentials) => Promise; + readonly logout: () => Promise; + readonly clearSession: (loginNotice?: string | null) => void; +}; + +export const AuthSessionContext = createContext(null); + +export function useAuthSession(): AuthSessionContextValue { + const context = useContext(AuthSessionContext); + + if (context === null) { + throw new MissingAuthSessionProviderError(); + } + + return context; +} diff --git a/src/features/auth/model/auth-session-storage.test.ts b/src/features/auth/model/auth-session-storage.test.ts new file mode 100644 index 0000000..976814f --- /dev/null +++ b/src/features/auth/model/auth-session-storage.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; + +import { + authSessionStorage, + type AuthSessionRecord, +} from "./auth-session-storage"; + +describe("authSessionStorage", () => { + const session: AuthSessionRecord = { + token: "header.payload.signature", + role: "ADMIN", + }; + + test("reads a saved ADMIN session", () => { + // Given + authSessionStorage.save(session); + + // When + const restoredSession = authSessionStorage.read(); + + // Then + expect(restoredSession).toEqual(session); + }); + + test("removes a saved session", () => { + // Given + authSessionStorage.save(session); + + // When + authSessionStorage.remove(); + + // Then + expect(authSessionStorage.read()).toBeNull(); + }); +}); diff --git a/src/features/auth/model/auth-session-storage.ts b/src/features/auth/model/auth-session-storage.ts new file mode 100644 index 0000000..d5c6d11 --- /dev/null +++ b/src/features/auth/model/auth-session-storage.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +export type AuthSessionRecord = { + readonly token: string; + readonly role: "ADMIN"; +}; + +const authSessionStorageKey = "ai-character-admin-auth-session"; +const authSessionRecordSchema = z.object({ + token: z.string().min(1), + role: z.literal("ADMIN"), +}); + +function parseAuthSession(value: string): AuthSessionRecord | null { + try { + return authSessionRecordSchema.parse(JSON.parse(value)); + } catch (error) { + if (error instanceof SyntaxError || error instanceof z.ZodError) { + return null; + } + + throw error; + } +} + +export const authSessionStorage = { + read(): AuthSessionRecord | null { + const value = sessionStorage.getItem(authSessionStorageKey); + + return value === null ? null : parseAuthSession(value); + }, + save(session: AuthSessionRecord): void { + sessionStorage.setItem(authSessionStorageKey, JSON.stringify(session)); + }, + remove(): void { + sessionStorage.removeItem(authSessionStorageKey); + }, +}; diff --git a/src/features/auth/model/auth-session.tsx b/src/features/auth/model/auth-session.tsx new file mode 100644 index 0000000..37d53f5 --- /dev/null +++ b/src/features/auth/model/auth-session.tsx @@ -0,0 +1,77 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; + +import { login as requestLogin, logout as requestLogout } from "@/features/auth/api/auth-api"; +import { AuthSessionContext } from "@/features/auth/model/auth-session-context"; +import type { AuthSessionContextValue } from "@/features/auth/model/auth-session-context"; +import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; +import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage"; +import type { LoginCredentials } from "@/features/auth/schemas/login-schema"; +import type { ApiClient } from "@/shared/api/client"; + +const logoutFailureWarning = "서버 로그아웃 확인에 실패했습니다."; + +export type AuthSessionProviderProps = { + readonly apiClient: ApiClient; + readonly children: ReactNode; + readonly onNavigateLogin?: (path: "/login") => void; +}; + +export function AuthSessionProvider({ apiClient, children, onNavigateLogin }: AuthSessionProviderProps) { + const [session, setSession] = useState(() => authSessionStorage.read()); + const [loginNotice, setLoginNotice] = useState(null); + const logoutPromiseRef = useRef | null>(null); + + const clearSession = useCallback((nextLoginNotice: string | null = null) => { + authSessionStorage.remove(); + setSession(null); + setLoginNotice(nextLoginNotice); + }, []); + + const login = useCallback( + async (credentials: LoginCredentials) => { + const nextSession = await requestLogin(apiClient, credentials); + authSessionStorage.save(nextSession); + setSession(nextSession); + setLoginNotice(null); + }, + [apiClient], + ); + + const logout = useCallback(async () => { + if (logoutPromiseRef.current !== null) { + return logoutPromiseRef.current; + } + + const logoutPromise = (async () => { + let didLogoutRequestFail = false; + + try { + await requestLogout(apiClient); + } catch (error) { + if (error instanceof Error) { + didLogoutRequestFail = true; + } else { + didLogoutRequestFail = true; + } + } + + authSessionStorage.remove(); + setSession(null); + setLoginNotice(didLogoutRequestFail ? logoutFailureWarning : null); + onNavigateLogin?.("/login"); + })().finally(() => { + logoutPromiseRef.current = null; + }); + + logoutPromiseRef.current = logoutPromise; + return logoutPromise; + }, [apiClient, onNavigateLogin]); + + const value = useMemo( + () => ({ session, loginNotice, login, logout, clearSession }), + [clearSession, login, loginNotice, logout, session], + ); + + return {children}; +} diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx new file mode 100644 index 0000000..8c9c1b6 --- /dev/null +++ b/src/features/auth/pages/LoginPage.tsx @@ -0,0 +1,156 @@ +import { useId, useRef, useState } from "react"; +import { z } from "zod"; + +import { loginSchema } from "@/features/auth/schemas/login-schema"; +import type { LoginCredentials } from "@/features/auth/schemas/login-schema"; + +type LoginFieldErrors = { + readonly email: string | null; + readonly password: string | null; +}; + +export type LoginPageProps = { + readonly notice?: string | null; + readonly onSubmit: (credentials: LoginCredentials) => Promise; +}; + +const emptyErrors: LoginFieldErrors = { email: null, password: null }; + +function getCredentials(form: HTMLFormElement): LoginCredentials { + const formData = new FormData(form); + + return { + email: String(formData.get("email") ?? ""), + password: String(formData.get("password") ?? ""), + }; +} + +function getFieldErrors(error: z.ZodError): LoginFieldErrors { + const fieldErrors = z.flattenError(error).fieldErrors; + + return { + email: fieldErrors.email?.[0] ?? null, + password: fieldErrors.password?.[0] ?? null, + }; +} + +export function LoginPage({ notice = null, onSubmit }: LoginPageProps) { + const emailErrorId = useId(); + const passwordErrorId = useId(); + const emailRef = useRef(null); + const passwordRef = useRef(null); + const [errors, setErrors] = useState(emptyErrors); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(form: HTMLFormElement): Promise { + const parsedCredentials = loginSchema.safeParse(getCredentials(form)); + + if (!parsedCredentials.success) { + const nextErrors = getFieldErrors(parsedCredentials.error); + setErrors(nextErrors); + + if (nextErrors.email !== null) { + emailRef.current?.focus(); + } else if (nextErrors.password !== null) { + passwordRef.current?.focus(); + } + + return; + } + + setErrors(emptyErrors); + setSubmitError(null); + setIsSubmitting(true); + try { + await onSubmit(parsedCredentials.data); + } catch (error) { + if (error instanceof Error) { + setSubmitError(error.message || "로그인에 실패했습니다."); + return; + } + + throw error; + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+

AI CHARACTER ADMIN

+

관리자 로그인

+

ADMIN 계정으로 로그인하세요.

+
+ {notice === null ? null : ( +

+ {notice} +

+ )} +
{ + event.preventDefault(); + void handleSubmit(event.currentTarget); + }} + > +
+ + + {errors.email === null ? null : ( + + )} +
+
+ + + {errors.password === null ? null : ( + + )} +
+ {submitError === null ? null : ( +

+ {submitError} +

+ )} + +
+
+
+ ); +} diff --git a/src/features/auth/schemas/login-schema.ts b/src/features/auth/schemas/login-schema.ts new file mode 100644 index 0000000..258441c --- /dev/null +++ b/src/features/auth/schemas/login-schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + email: z.email("올바른 이메일을 입력하세요."), + password: z.string().min(1, "비밀번호를 입력하세요."), +}); + +export type LoginCredentials = z.infer; diff --git a/src/features/auth/tests/auth-api.test.ts b/src/features/auth/tests/auth-api.test.ts new file mode 100644 index 0000000..754c1c9 --- /dev/null +++ b/src/features/auth/tests/auth-api.test.ts @@ -0,0 +1,107 @@ +import { http, HttpResponse } from "msw"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { login, logout } from "@/features/auth/api/auth-api"; +import { createApiClient } from "@/shared/api/client"; +import { server } from "@/shared/test/server"; + +const apiBaseUrl = "https://api.example.com"; + +function createClient(token: string | null = "header.payload.signature") { + return createApiClient({ + getToken: () => token, + clearSession: vi.fn(), + onAuthExpired: vi.fn(), + }); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("auth API", () => { + test("posts email and password JSON only to admin login without Authorization", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const client = createClient("caller-token"); + const observedRequests: string[] = []; + let authorization: string | null = null; + let contentType: string | null = null; + let body: unknown = null; + server.use( + http.post(`${apiBaseUrl}/admin/member/login`, async ({ request }) => { + observedRequests.push(new URL(request.url).pathname); + authorization = request.headers.get("Authorization"); + contentType = request.headers.get("Content-Type"); + body = await request.json(); + return HttpResponse.json({ + success: true, + message: null, + data: { token: "jwt-token", role: "ADMIN" }, + errorProperty: null, + }); + }), + http.all(`${apiBaseUrl}/refresh`, ({ request }) => { + observedRequests.push(new URL(request.url).pathname); + return HttpResponse.json({ success: false, message: "unexpected", data: null }, { status: 500 }); + }), + ); + + // When + const session = await login(client, { email: "admin@test.com", password: "password" }); + + // Then + expect(session).toEqual({ token: "jwt-token", role: "ADMIN" }); + expect(authorization).toBeNull(); + expect(contentType).toContain("application/json"); + expect(body).toEqual({ email: "admin@test.com", password: "password" }); + expect(observedRequests).toEqual(["/admin/member/login"]); + }); + + test.each([ + { name: "empty token", data: { token: "", role: "ADMIN" } }, + { name: "missing token", data: { role: "ADMIN" } }, + { name: "missing role", data: { token: "jwt-token" } }, + { name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } }, + ])("rejects $name login response", async ({ data }) => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const client = createClient(); + server.use( + http.post(`${apiBaseUrl}/admin/member/login`, () => + HttpResponse.json({ success: true, message: null, data, errorProperty: null }), + ), + ); + + // When + const request = login(client, { email: "admin@test.com", password: "password" }); + + // Then + await expect(request).rejects.toThrow("API 응답 형식이 올바르지 않습니다."); + }); + + test("posts logout once with Bearer and no body", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const client = createClient("logout-token"); + let calls = 0; + let authorization: string | null = null; + let body = "not-read"; + server.use( + http.post(`${apiBaseUrl}/member/logout`, async ({ request }) => { + calls += 1; + authorization = request.headers.get("Authorization"); + body = await request.text(); + return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null }); + }), + ); + + // When + await logout(client); + + // Then + expect(calls).toBe(1); + expect(authorization).toBe("Bearer logout-token"); + expect(body).toBe(""); + }); +}); diff --git a/src/features/auth/tests/auth-session.test.tsx b/src/features/auth/tests/auth-session.test.tsx new file mode 100644 index 0000000..711179c --- /dev/null +++ b/src/features/auth/tests/auth-session.test.tsx @@ -0,0 +1,203 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { useAuthSession } from "@/features/auth/model/auth-session-context"; +import { AuthSessionProvider } from "@/features/auth/model/auth-session"; +import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; +import { createApiClient } from "@/shared/api/client"; +import { server } from "@/shared/test/server"; + +const apiBaseUrl = "https://api.example.com"; + +function SessionProbe() { + const auth = useAuthSession(); + const [loginFailed, setLoginFailed] = useState(false); + + return ( +
+
{auth.session === null ? "보호 콘텐츠 없음" : "보호 콘텐츠"}
+
{auth.session?.token ?? "토큰 없음"}
+ {loginFailed ?

로그인 거부

: null} + {auth.loginNotice === null ? null :

{auth.loginNotice}

} + + +
+ ); +} + +function renderSession(onNavigateLogin = vi.fn()) { + const client = createApiClient({ + getToken: () => authSessionStorage.read()?.token ?? null, + clearSession: authSessionStorage.remove, + onAuthExpired: onNavigateLogin, + }); + + render( + + + , + ); + + return { onNavigateLogin }; +} + +afterEach(() => { + vi.unstubAllEnvs(); + document.cookie = "auth=; Max-Age=0; path=/"; +}); + +describe("auth session model", () => { + test("rejects stored session with empty token", () => { + // Given + sessionStorage.setItem( + "ai-character-admin-auth-session", + JSON.stringify({ token: "", role: "ADMIN" }), + ); + + // When, Then + expect(authSessionStorage.read()).toBeNull(); + }); + + test("stores successful ADMIN login only in sessionStorage and restores it in the same tab", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const indexedDbOpen = vi.fn(); + vi.stubGlobal("indexedDB", { open: indexedDbOpen }); + const cookieBefore = document.cookie; + server.use( + http.post(`${apiBaseUrl}/admin/member/login`, () => + HttpResponse.json({ + success: true, + message: null, + data: { token: "jwt-token", role: "ADMIN" }, + errorProperty: null, + }), + ), + ); + + // When + renderSession(); + screen.getByRole("button", { name: "로그인 실행" }).click(); + + // Then + await screen.findByText("jwt-token"); + expect(authSessionStorage.read()).toEqual({ token: "jwt-token", role: "ADMIN" }); + expect(localStorage).toHaveLength(0); + expect(indexedDbOpen).not.toHaveBeenCalled(); + expect(document.cookie).toBe(cookieBefore); + + // When + renderSession(); + + // Then + expect(screen.getAllByText("jwt-token")).toHaveLength(2); + }); + + test.each([ + { name: "empty token", data: { token: "", role: "ADMIN" } }, + { name: "missing token", data: { role: "ADMIN" } }, + { name: "missing role", data: { token: "jwt-token" } }, + { name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } }, + ])("rejects $name without storing or exposing protected content", async ({ data }) => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + server.use( + http.post(`${apiBaseUrl}/admin/member/login`, () => + HttpResponse.json({ success: true, message: null, data, errorProperty: null }), + ), + ); + + // When + renderSession(); + screen.getByRole("button", { name: "로그인 실행" }).click(); + + // Then + await waitFor(() => expect(authSessionStorage.read()).toBeNull()); + expect(screen.queryByText("보호 콘텐츠", { exact: true })).not.toBeInTheDocument(); + expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument(); + }); + + test.each([ + { name: "success", response: "success" }, + { name: "non-2xx", response: "server-error" }, + { name: "network error", response: "network-error" }, + ])("removes local session and navigates to login after logout $name", async ({ response }) => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + authSessionStorage.save({ token: "logout-token", role: "ADMIN" }); + const onNavigateLogin = vi.fn(); + server.use( + http.post(`${apiBaseUrl}/member/logout`, () => { + if (response === "network-error") { + return HttpResponse.error(); + } + if (response === "server-error") { + return HttpResponse.json( + { success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null }, + { status: 500 }, + ); + } + return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null }); + }), + ); + + // When + renderSession(onNavigateLogin); + screen.getByRole("button", { name: "로그아웃 실행" }).click(); + + // Then + await waitFor(() => expect(authSessionStorage.read()).toBeNull()); + expect(onNavigateLogin).toHaveBeenCalledExactlyOnceWith("/login"); + expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument(); + if (response === "success") { + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + } else { + expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다."); + } + }); + + test("coalesces duplicate logout clicks while the logout request is in flight", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + authSessionStorage.save({ token: "logout-token", role: "ADMIN" }); + let logoutCount = 0; + let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined; + const logoutReady = new Promise<() => void>((resolve) => { + resolveLogoutReady = resolve; + }); + server.use( + http.post(`${apiBaseUrl}/member/logout`, () => { + logoutCount += 1; + + return new Promise((resolve) => { + resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null }))); + }); + }), + ); + + // When + renderSession(); + const logoutButton = screen.getByRole("button", { name: "로그아웃 실행" }); + logoutButton.click(); + logoutButton.click(); + + // Then + await waitFor(() => expect(logoutCount).toBe(1)); + const finishLogout = await logoutReady; + finishLogout(); + await waitFor(() => expect(authSessionStorage.read()).toBeNull()); + }); +}); diff --git a/src/features/auth/tests/login-page.test.tsx b/src/features/auth/tests/login-page.test.tsx new file mode 100644 index 0000000..a5bb0e6 --- /dev/null +++ b/src/features/auth/tests/login-page.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { LoginPage } from "@/features/auth/pages/LoginPage"; + +describe("LoginPage", () => { + test("renders visible email and password labels", () => { + // Given, When + render(); + + // Then + expect(screen.getByLabelText("이메일")).toHaveAttribute("type", "email"); + expect(screen.getByLabelText("비밀번호")).toHaveAttribute("type", "password"); + }); + + test("renders a login notice as an alert", () => { + // Given, When + render(); + + // Then + expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요."); + }); + + test("connects field errors and focuses the first invalid field", async () => { + // Given + render(); + + // When + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + + // Then + const email = screen.getByLabelText("이메일"); + const password = screen.getByLabelText("비밀번호"); + const emailError = await screen.findByText("올바른 이메일을 입력하세요."); + const passwordError = screen.getByText("비밀번호를 입력하세요."); + expect(email).toHaveAccessibleDescription("올바른 이메일을 입력하세요."); + expect(password).toHaveAccessibleDescription("비밀번호를 입력하세요."); + expect(email).toHaveAttribute("aria-describedby", emailError.id); + expect(password).toHaveAttribute("aria-describedby", passwordError.id); + expect(email).toHaveFocus(); + }); + + test("submits valid email and password", async () => { + // Given + const onSubmit = vi.fn<() => Promise>(() => Promise.resolve()); + render(); + + // When + fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } }); + fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } }); + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + + // Then + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + email: "admin@test.com", + password: "password", + }), + ); + }); + + test("re-enables submit button when login submission fails", async () => { + // Given + const onSubmit = vi.fn<() => Promise>(() => Promise.reject(new Error("login failed"))); + render(); + + // When + fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } }); + fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } }); + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + + // Then + await waitFor(() => expect(screen.getByRole("button", { name: "로그인" })).toBeEnabled()); + }); + + test("shows the server Korean error message when login submission fails with one", async () => { + // Given + const onSubmit = vi.fn<() => Promise>(() => Promise.reject(new Error("이메일 또는 비밀번호가 올바르지 않습니다."))); + render(); + + // When + fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } }); + fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } }); + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent("이메일 또는 비밀번호가 올바르지 않습니다."); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index 87aae0c..8fa1208 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { QueryClientProvider } from "@tanstack/react-query"; import { App } from "@/app/App"; +import { queryClient } from "@/shared/api/query-client"; +import "@/styles/globals.css"; import { getRuntimeEnv } from "@/shared/config/env"; getRuntimeEnv(); @@ -14,6 +17,8 @@ if (!root) { createRoot(root).render( - + + + , ); diff --git a/src/shared/api/__tests__/client-auth.test.ts b/src/shared/api/__tests__/client-auth.test.ts new file mode 100644 index 0000000..288136a --- /dev/null +++ b/src/shared/api/__tests__/client-auth.test.ts @@ -0,0 +1,215 @@ +import { http, HttpResponse } from "msw"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { AccessDeniedError } from "../api-error"; +import { createApiClient } from "../client"; +import { server } from "../../test/server"; +import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("authenticated API requests", () => { + test("sends Korean language without bearer authentication to login", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient(); + let acceptLanguage: string | null = null; + let authorization: string | null = null; + server.use( + http.post(`${apiBaseUrl}/admin/member/login`, ({ request }) => { + acceptLanguage = request.headers.get("Accept-Language"); + authorization = request.headers.get("Authorization"); + return HttpResponse.json({ success: true, message: null, data: { value: "ok" } }); + }), + ); + + // When + await client.request({ + path: "/admin/member/login", + method: "POST", + headers: { + Authorization: "Bearer caller-supplied-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "admin@test.com", password: "secret" }), + responseSchema: valueSchema, + authentication: "none", + }); + + // Then + expect(acceptLanguage).toBe("ko"); + expect(authorization).toBeNull(); + }); + + test("sends bearer authentication to a protected request", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient(); + let authorization: string | null = null; + server.use( + http.get(`${apiBaseUrl}/api/v2/protected`, ({ request }) => { + authorization = request.headers.get("Authorization"); + return HttpResponse.json({ success: true, message: null, data: { value: "ok" } }); + }), + ); + + // When + await client.request({ + path: "/api/v2/protected", + responseSchema: valueSchema, + authentication: "required", + }); + + // Then + expect(authorization).toBe("Bearer header.payload.signature"); + }); + + test("sends bearer authentication to logout", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient(); + let authorization: string | null = null; + server.use( + http.post(`${apiBaseUrl}/member/logout`, ({ request }) => { + authorization = request.headers.get("Authorization"); + return HttpResponse.json({ success: true, message: null, data: { value: "ok" } }); + }), + ); + + // When + await client.request({ + path: "/member/logout", + method: "POST", + responseSchema: valueSchema, + authentication: "required", + }); + + // Then + expect(authorization).toBe("Bearer header.payload.signature"); + }); + + test("clears the session and redirects once for each concurrent 401 burst", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const clearSession = vi.fn(); + const onAuthExpired = vi.fn(); + const client = createApiClient({ + getToken: () => "header.payload.signature", + clearSession, + onAuthExpired, + }); + server.use( + http.get(`${apiBaseUrl}/protected`, () => + HttpResponse.json( + { + success: false, + message: "인증 정보가 없습니다.", + data: null, + errorProperty: null, + }, + { status: 401 }, + ), + ), + ); + + // When + const firstBurst = await Promise.allSettled([ + client.request({ + path: "/protected", + responseSchema: valueSchema, + authentication: "required", + }), + client.request({ + path: "/protected", + responseSchema: valueSchema, + authentication: "required", + }), + ]); + const secondBurst = await Promise.allSettled([ + client.request({ + path: "/protected", + responseSchema: valueSchema, + authentication: "required", + }), + client.request({ + path: "/protected", + responseSchema: valueSchema, + authentication: "required", + }), + ]); + + // Then + expect(firstBurst).toHaveLength(2); + expect(secondBurst).toHaveLength(2); + expect(clearSession).toHaveBeenCalledTimes(2); + expect(onAuthExpired).toHaveBeenCalledTimes(2); + }); + + test("surfaces access denied without clearing the session", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client, clearSession, onAuthExpired } = createTestClient(); + server.use( + http.get(`${apiBaseUrl}/protected`, () => + HttpResponse.json( + { + success: false, + message: "접근 권한이 없습니다.", + data: null, + errorProperty: null, + }, + { status: 403 }, + ), + ), + ); + + // When + const request = client.request({ + path: "/protected", + responseSchema: valueSchema, + authentication: "required", + }); + + // Then + await expect(request).rejects.toBeInstanceOf(AccessDeniedError); + expect(clearSession).not.toHaveBeenCalled(); + expect(onAuthExpired).not.toHaveBeenCalled(); + }); + + test("does not write sensitive request data to the console", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient("jwt.secret.value"); + const multipartBody = new FormData(); + multipartBody.append("request", JSON.stringify({ password: "secret-password" })); + multipartBody.append("file", new File(["private-content"], "private.txt")); + const consoleDebug = vi.spyOn(console, "debug"); + const consoleError = vi.spyOn(console, "error"); + const consoleInfo = vi.spyOn(console, "info"); + const consoleLog = vi.spyOn(console, "log"); + const consoleWarn = vi.spyOn(console, "warn"); + server.use( + http.post(`${apiBaseUrl}/api/v2/protected`, () => + HttpResponse.json({ success: true, message: null, data: { value: "ok" } }), + ), + ); + + // When + await client.request({ + path: "/api/v2/protected?signedUrl=https%3A%2F%2Fcdn.example.com%2Fprivate%3Fsignature%3Dabc", + method: "POST", + body: multipartBody, + responseSchema: valueSchema, + authentication: "required", + }); + + // Then + expect(consoleDebug).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + expect(consoleInfo).not.toHaveBeenCalled(); + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/api/__tests__/client-test-helpers.ts b/src/shared/api/__tests__/client-test-helpers.ts new file mode 100644 index 0000000..5cc7baa --- /dev/null +++ b/src/shared/api/__tests__/client-test-helpers.ts @@ -0,0 +1,31 @@ +import { vi } from "vitest"; +import { z } from "zod"; + +import { createApiClient } from "../client"; + +export const apiBaseUrl = "https://api.example.com"; +export const valueSchema = z.object({ value: z.string() }); + +export type TestClient = { + readonly client: ReturnType; + readonly clearSession: ReturnType; + readonly onAuthExpired: ReturnType; +}; + +export function createTestClient(token = "header.payload.signature"): TestClient { + let currentToken: string | null = token; + const clearSession = vi.fn(() => { + currentToken = null; + }); + const onAuthExpired = vi.fn(); + + return { + client: createApiClient({ + getToken: () => currentToken, + clearSession, + onAuthExpired, + }), + clearSession, + onAuthExpired, + }; +} diff --git a/src/shared/api/__tests__/client.test.ts b/src/shared/api/__tests__/client.test.ts new file mode 100644 index 0000000..e5d5e4c --- /dev/null +++ b/src/shared/api/__tests__/client.test.ts @@ -0,0 +1,117 @@ +import { http, HttpResponse } from "msw"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { ApiError } from "../api-error"; +import { server } from "../../test/server"; +import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("API client", () => { + test("accepts a successful envelope without errorProperty", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient(); + server.use( + http.get(`${apiBaseUrl}/public`, () => + HttpResponse.json({ success: true, message: null, data: { value: "ok" } }), + ), + ); + + // When + const response = await client.request({ + path: "/public", + responseSchema: valueSchema, + authentication: "none", + }); + + // Then + expect(response).toEqual({ value: "ok" }); + }); + + test("accepts a successful envelope with null errorProperty", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient(); + server.use( + http.get(`${apiBaseUrl}/public`, () => + HttpResponse.json({ + success: true, + message: null, + data: { value: "ok" }, + errorProperty: null, + }), + ), + ); + + // When + const response = await client.request({ + path: "/public", + responseSchema: valueSchema, + authentication: "none", + }); + + // Then + expect(response).toEqual({ value: "ok" }); + }); + + test.each([400, 404, 405, 415, 500])( + "preserves a Korean server error envelope for status %i", + async (status) => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient(); + const message = `서버 오류 ${status}`; + const errorProperty = "request"; + server.use( + http.get(`${apiBaseUrl}/error`, () => + HttpResponse.json( + { success: false, message, data: null, errorProperty }, + { status }, + ), + ), + ); + + // When + const request = client.request({ + path: "/error", + responseSchema: valueSchema, + authentication: "none", + }); + + // Then + await expect(request).rejects.toMatchObject({ status, message, errorProperty }); + }, + ); + + test("exposes server errors as ApiError", async () => { + // Given + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + const { client } = createTestClient(); + server.use( + http.get(`${apiBaseUrl}/error`, () => + HttpResponse.json( + { + success: false, + message: "잘못된 요청입니다.", + data: null, + errorProperty: null, + }, + { status: 400 }, + ), + ), + ); + + // When + const request = client.request({ + path: "/error", + responseSchema: valueSchema, + authentication: "none", + }); + + // Then + await expect(request).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/src/shared/api/__tests__/pagination.test.ts b/src/shared/api/__tests__/pagination.test.ts new file mode 100644 index 0000000..29ae289 --- /dev/null +++ b/src/shared/api/__tests__/pagination.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "vitest"; + +import { createPageParams, type PageData } from "../pagination"; + +describe("createPageParams", () => { + test("uses documented default page and size", () => { + // Given + const request = {}; + + // When + const pageParams = createPageParams(request); + + // Then + expect(pageParams).toEqual({ page: 0, size: 20 }); + }); + + test("clamps size to the documented lower bound", () => { + // Given + const request = { size: 1 }; + + // When + const pageParams = createPageParams(request); + + // Then + expect(pageParams.size).toBe(20); + }); + + test("clamps size to the documented upper bound", () => { + // Given + const request = { size: 51 }; + + // When + const pageParams = createPageParams(request); + + // Then + expect(pageParams.size).toBe(50); + }); + + test("keeps a provided page unchanged", () => { + // Given + const request = { page: 3, size: 20 }; + + // When + const pageParams = createPageParams(request); + + // Then + expect(pageParams.page).toBe(3); + }); + + test("defines the documented page response shape", () => { + // Given + const page: PageData = { + totalCount: 1, + page: 0, + size: 20, + hasNext: false, + items: ["루나"], + }; + + // When + const firstItem = page.items[0]; + + // Then + expect(firstItem).toBe("루나"); + }); +}); diff --git a/src/shared/api/__tests__/query-client.test.ts b/src/shared/api/__tests__/query-client.test.ts new file mode 100644 index 0000000..68e4279 --- /dev/null +++ b/src/shared/api/__tests__/query-client.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; + +import { ApiError } from "../api-error"; +import { createQueryClient, shouldRetryQuery } from "../query-client"; + +describe("TanStack Query defaults", () => { + test("does not retry unauthenticated query errors", () => { + // Given + const error = new ApiError({ + status: 401, + message: "인증 정보가 없습니다.", + errorProperty: null, + }); + + // When + const shouldRetry = shouldRetryQuery(0, error); + + // Then + expect(shouldRetry).toBe(false); + }); + + test("does not retry access-denied query errors", () => { + // Given + const error = new ApiError({ + status: 403, + message: "접근 권한이 없습니다.", + errorProperty: null, + }); + + // When + const shouldRetry = shouldRetryQuery(0, error); + + // Then + expect(shouldRetry).toBe(false); + }); + + test("limits retries for other query errors", () => { + // Given + const error = new Error("network failure"); + + // When + const shouldRetry = shouldRetryQuery(2, error); + + // Then + expect(shouldRetry).toBe(false); + }); + + test("disables mutation retries", () => { + // Given + const queryClient = createQueryClient(); + + // When + const retry = queryClient.getDefaultOptions().mutations?.retry; + + // Then + expect(retry).toBe(false); + }); +}); diff --git a/src/shared/api/api-error.ts b/src/shared/api/api-error.ts new file mode 100644 index 0000000..c81f2cc --- /dev/null +++ b/src/shared/api/api-error.ts @@ -0,0 +1,21 @@ +export type ApiErrorOptions = { + readonly status: number; + readonly message: string; + readonly errorProperty: string | null; +}; + +export class ApiError extends Error { + override readonly name: string = "ApiError"; + readonly status: number; + readonly errorProperty: string | null; + + constructor(options: ApiErrorOptions) { + super(options.message); + this.status = options.status; + this.errorProperty = options.errorProperty; + } +} + +export class AccessDeniedError extends ApiError { + override readonly name: string = "AccessDeniedError"; +} diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts new file mode 100644 index 0000000..fb73c82 --- /dev/null +++ b/src/shared/api/client.ts @@ -0,0 +1,131 @@ +import type { z } from "zod"; + +import { AccessDeniedError, ApiError } from "./api-error"; +import { getRuntimeEnv } from "../config/env"; +import { createApiResponseSchema } from "./types"; + +type AuthenticationMode = "none" | "required"; + +export type ApiClientDependencies = { + readonly getToken: () => string | null; + readonly clearSession: () => void; + readonly onAuthExpired: () => void; +}; + +export type ApiRequestOptions = { + readonly path: string; + readonly responseSchema: z.ZodType; + readonly authentication: AuthenticationMode; + readonly method?: string; + readonly headers?: HeadersInit; + readonly body?: BodyInit | null; +}; + +export type ApiClient = { + readonly request: (options: ApiRequestOptions) => Promise; +}; + +function toApiError( + status: number, + response: { + readonly message: string; + readonly errorProperty: string | null; + }, +): ApiError { + if (status === 403) { + return new AccessDeniedError({ + status, + message: response.message, + errorProperty: response.errorProperty, + }); + } + + return new ApiError({ + status, + message: response.message, + errorProperty: response.errorProperty, + }); +} + +export function createApiClient(dependencies: ApiClientDependencies): ApiClient { + let hasHandledAuthenticationExpiry = false; + let activeProtectedRequestCount = 0; + + return { + async request(options: ApiRequestOptions): Promise { + const isProtectedRequest = options.authentication === "required"; + + if (isProtectedRequest) { + activeProtectedRequestCount += 1; + } + + try { + const headers = new Headers(options.headers); + headers.set("Accept-Language", "ko"); + headers.delete("Authorization"); + + if (isProtectedRequest) { + const token = dependencies.getToken(); + + if (token !== null) { + headers.set("Authorization", `Bearer ${token}`); + } + } + + const init: RequestInit = { headers }; + + if (options.method !== undefined) { + init.method = options.method; + } + if (options.body !== undefined) { + init.body = options.body; + } + + const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init); + const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse( + await response.json(), + ); + + if (!parsedResponse.success) { + throw new ApiError({ + status: response.status, + message: "API 응답 형식이 올바르지 않습니다.", + errorProperty: null, + }); + } + + const apiResponse = parsedResponse.data; + + if (response.ok && apiResponse.success) { + return apiResponse.data; + } + + if (!apiResponse.success) { + if (response.status === 401 && isProtectedRequest) { + if (!hasHandledAuthenticationExpiry) { + hasHandledAuthenticationExpiry = true; + dependencies.clearSession(); + dependencies.onAuthExpired(); + } + } + + throw toApiError(response.status, apiResponse); + } + + throw new ApiError({ + status: response.status, + message: "API 오류 응답 형식이 올바르지 않습니다.", + errorProperty: null, + }); + } finally { + if (isProtectedRequest) { + activeProtectedRequestCount -= 1; + + if (activeProtectedRequestCount === 0) { + hasHandledAuthenticationExpiry = false; + } + } + } + }, + }; +} diff --git a/src/shared/api/pagination.ts b/src/shared/api/pagination.ts new file mode 100644 index 0000000..f749d5d --- /dev/null +++ b/src/shared/api/pagination.ts @@ -0,0 +1,24 @@ +export type PageData = { + readonly totalCount: number; + readonly page: number; + readonly size: number; + readonly hasNext: boolean; + readonly items: readonly Item[]; +}; + +export type PageParams = { + readonly page?: number; + readonly size?: number; +}; + +export function createPageParams(params: PageParams = {}): { + readonly page: number; + readonly size: number; +} { + const size = params.size ?? 20; + + return { + page: params.page ?? 0, + size: Math.min(Math.max(size, 20), 50), + }; +} diff --git a/src/shared/api/query-client.ts b/src/shared/api/query-client.ts new file mode 100644 index 0000000..80894cf --- /dev/null +++ b/src/shared/api/query-client.ts @@ -0,0 +1,22 @@ +import { QueryClient } from "@tanstack/react-query"; + +import { ApiError } from "./api-error"; + +export function shouldRetryQuery(failureCount: number, error: unknown): boolean { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + return false; + } + + return failureCount < 2; +} + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: shouldRetryQuery }, + mutations: { retry: false }, + }, + }); +} + +export const queryClient = createQueryClient(); diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts new file mode 100644 index 0000000..bd20023 --- /dev/null +++ b/src/shared/api/types.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export type ApiSuccessResponse = { + readonly success: true; + readonly message: null; + readonly data: Data; + readonly errorProperty?: null; +}; + +export type ApiErrorResponse = { + readonly success: false; + readonly message: string; + readonly data: null; + readonly errorProperty: string | null; +}; + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +export function createApiResponseSchema(dataSchema: z.ZodType) { + return z.discriminatedUnion("success", [ + z.object({ + success: z.literal(true), + message: z.null(), + data: dataSchema, + errorProperty: z.null().optional(), + }), + z.object({ + success: z.literal(false), + message: z.string(), + data: z.null(), + errorProperty: z.string().nullable(), + }), + ]); +} diff --git a/src/shared/lib/__tests__/formatters.test.ts b/src/shared/lib/__tests__/formatters.test.ts new file mode 100644 index 0000000..97659b5 --- /dev/null +++ b/src/shared/lib/__tests__/formatters.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "vitest"; + +import { formatCanAmount, formatSeoulDateTime } from "@/shared/lib/formatters"; + +test("formatSeoulDateTime displays UTC input in Asia/Seoul", () => { + expect(formatSeoulDateTime("2026-07-25T15:30:00.000Z")).toBe("2026. 07. 26. 00:30"); +}); + +test("formatCanAmount displays non-negative integer can units without domain status labels", () => { + expect(formatCanAmount(0)).toBe("0캔"); + expect(formatCanAmount(12345)).toBe("12,345캔"); +}); diff --git a/src/shared/lib/crop-image.test.ts b/src/shared/lib/crop-image.test.ts new file mode 100644 index 0000000..3318dc8 --- /dev/null +++ b/src/shared/lib/crop-image.test.ts @@ -0,0 +1,103 @@ +import { expect, test, vi } from "vitest"; + +import { calculateCropOutputSize, calculateCropSourceRect, createCroppedImageFile } from "@/shared/lib/crop-image"; + +type RenderedCrop = { + readonly bottomAlpha: number; + readonly height: number; + readonly sourceHeight: number; + readonly sourceWidth: number; + readonly sourceX: number; + readonly sourceY: number; + readonly topAlpha: number; + readonly width: number; +}; + +function restoreDescriptor(property: "getContext" | "toBlob", descriptor: PropertyDescriptor | undefined): void { + if (descriptor === undefined) { + Reflect.deleteProperty(HTMLCanvasElement.prototype, property); + return; + } + + Object.defineProperty(HTMLCanvasElement.prototype, property, descriptor); +} + +test("calculateCropOutputSize caps width at maxWidth and keeps aspect height", () => { + expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 })).toEqual({ height: 600, width: 600 }); + expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1600, sourceWidth: 1200 })).toEqual({ height: 1414, width: 1000 }); +}); + +test("calculateCropOutputSize never upscales when noUpscale is true", () => { + expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: true, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 300, width: 600 }); + expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: false, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 400, width: 800 }); +}); + +test("calculateCropSourceRect crops the largest centered source rectangle for the requested aspect", () => { + expect(calculateCropSourceRect({ aspect: 1, offsetX: 0, offsetY: 0, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 }); + expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: -20, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 290, sourceY: 0, width: 600 }); +}); + +test("createCroppedImageFile keeps a wide no-upscale crop opaque at the top and bottom", async () => { + let renderedCrop: RenderedCrop | null = null; + const imageFile = new File(["wide"], "wide.png", { type: "image/png" }); + const outputSize = calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 }); + const originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext"); + const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "toBlob"); + + try { + Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { + configurable: true, + value: () => ({ + drawImage: (...args: readonly unknown[]) => { + const [sourceImage, sourceX, sourceY, sourceWidth, sourceHeight, targetX, targetY, targetWidth, targetHeight] = args; + if (!(sourceImage instanceof EventTarget) || typeof sourceX !== "number" || typeof sourceY !== "number" || typeof sourceWidth !== "number" || typeof sourceHeight !== "number" || typeof targetX !== "number" || typeof targetY !== "number" || typeof targetWidth !== "number" || typeof targetHeight !== "number") { + throw new Error("Unexpected crop render call"); + } + + renderedCrop = { + bottomAlpha: sourceY + sourceHeight <= 600 && targetY + targetHeight <= outputSize.height ? 255 : 0, + height: targetHeight, + sourceHeight, + sourceWidth, + sourceX, + sourceY, + topAlpha: sourceY >= 0 && targetY === 0 ? 255 : 0, + width: targetWidth, + }; + }, + }), + }); + Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", { + configurable: true, + value: (callback: BlobCallback) => { + callback(new Blob([JSON.stringify(renderedCrop)], { type: imageFile.type })); + }, + }); + vi.stubGlobal( + "Image", + class FakeImage extends EventTarget { + set src(_value: string) { + queueMicrotask(() => this.dispatchEvent(new Event("load"))); + } + }, + ); + + const croppedFile = await createCroppedImageFile({ + aspect: 1, + file: imageFile, + offsetX: 0, + offsetY: 0, + outputHeight: outputSize.height, + outputWidth: outputSize.width, + previewUrl: "blob:wide", + sourceHeight: 600, + sourceWidth: 1200, + zoom: 1, + }); + + await expect(croppedFile.text()).resolves.toBe(JSON.stringify({ bottomAlpha: 255, height: 600, sourceHeight: 600, sourceWidth: 600, sourceX: 300, sourceY: 0, topAlpha: 255, width: 600 })); + } finally { + restoreDescriptor("getContext", originalGetContext); + restoreDescriptor("toBlob", originalToBlob); + } +}); diff --git a/src/shared/lib/crop-image.ts b/src/shared/lib/crop-image.ts new file mode 100644 index 0000000..83d1427 --- /dev/null +++ b/src/shared/lib/crop-image.ts @@ -0,0 +1,101 @@ +export type CropOutputSizeRequest = { + readonly aspect: number | "free"; + readonly maxWidth: number; + readonly noUpscale: boolean; + readonly sourceHeight: number; + readonly sourceWidth: number; +}; + +export type CropOutputSize = { + readonly height: number; + readonly width: number; +}; + +export type CropRenderRequest = { + readonly aspect: number | "free"; + readonly file: File; + readonly offsetX: number; + readonly offsetY: number; + readonly outputHeight: number; + readonly outputWidth: number; + readonly previewUrl: string; + readonly sourceHeight: number; + readonly sourceWidth: number; + readonly zoom: number; +}; + +export type CropSourceRectRequest = { + readonly aspect: number | "free"; + readonly offsetX: number; + readonly offsetY: number; + readonly sourceHeight: number; + readonly sourceWidth: number; + readonly zoom: number; +}; + +export type CropSourceRect = { + readonly height: number; + readonly sourceX: number; + readonly sourceY: number; + readonly width: number; +}; + +function getAspect(aspect: number | "free", sourceWidth: number, sourceHeight: number): number { + return aspect === "free" ? sourceWidth / sourceHeight : aspect; +} + +export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect { + const cropAspect = getAspect(aspect, sourceWidth, sourceHeight); + const sourceAspect = sourceWidth / sourceHeight; + const baseWidth = sourceAspect > cropAspect ? Math.round(sourceHeight * cropAspect) : sourceWidth; + const baseHeight = sourceAspect > cropAspect ? sourceHeight : Math.round(sourceWidth / cropAspect); + const width = Math.round(baseWidth / zoom); + const height = Math.round(baseHeight / zoom); + const maxSourceX = sourceWidth - width; + const maxSourceY = sourceHeight - height; + const centeredX = Math.round((sourceWidth - width) / 2 - offsetX / zoom); + const centeredY = Math.round((sourceHeight - height) / 2 - offsetY / zoom); + + return { + height, + sourceX: Math.min(Math.max(centeredX, 0), maxSourceX), + sourceY: Math.min(Math.max(centeredY, 0), maxSourceY), + width, + }; +} + +export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth }: CropOutputSizeRequest): CropOutputSize { + const cropAspect = getAspect(aspect, sourceWidth, sourceHeight); + const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom: 1 }); + const width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth; + + return { height: Math.round(width / cropAspect), width }; +} + +export function createCroppedImageFile(request: CropRenderRequest): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => { + const canvas = document.createElement("canvas"); + canvas.width = request.outputWidth; + canvas.height = request.outputHeight; + const context = canvas.getContext("2d"); + if (context === null) { + reject(new Error("Canvas context unavailable")); + return; + } + + const cropRect = calculateCropSourceRect(request); + context.drawImage(image, cropRect.sourceX, cropRect.sourceY, cropRect.width, cropRect.height, 0, 0, request.outputWidth, request.outputHeight); + canvas.toBlob((blob) => { + if (blob === null) { + reject(new Error("Canvas result unavailable")); + return; + } + resolve(new File([blob], request.file.name, { type: request.file.type })); + }, request.file.type); + }); + image.addEventListener("error", () => reject(new Error("Image preview unavailable"))); + image.src = request.previewUrl; + }); +} diff --git a/src/shared/lib/formatters.ts b/src/shared/lib/formatters.ts new file mode 100644 index 0000000..0555969 --- /dev/null +++ b/src/shared/lib/formatters.ts @@ -0,0 +1,23 @@ +const seoulDateTimeFormatter = new Intl.DateTimeFormat("ko-KR", { + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + month: "2-digit", + timeZone: "Asia/Seoul", + year: "numeric", +}); + +const canAmountFormatter = new Intl.NumberFormat("ko-KR", { + maximumFractionDigits: 0, +}); + +export function formatSeoulDateTime(utcDateTime: string | Date): string { + const parts = Object.fromEntries(seoulDateTimeFormatter.formatToParts(new Date(utcDateTime)).map((part) => [part.type, part.value])); + + return `${parts.year}. ${parts.month}. ${parts.day}. ${parts.hour}:${parts.minute}`; +} + +export function formatCanAmount(amount: number): string { + return `${canAmountFormatter.format(Math.max(0, Math.trunc(amount)))}캔`; +} diff --git a/src/shared/test/server.ts b/src/shared/test/server.ts new file mode 100644 index 0000000..bd0bda5 --- /dev/null +++ b/src/shared/test/server.ts @@ -0,0 +1,3 @@ +import { setupServer } from "msw/node"; + +export const server = setupServer(); diff --git a/src/shared/test/setup-isolation.test.ts b/src/shared/test/setup-isolation.test.ts new file mode 100644 index 0000000..8c595d8 --- /dev/null +++ b/src/shared/test/setup-isolation.test.ts @@ -0,0 +1,11 @@ +import { expect, test, vi } from "vitest"; + +test("test setup can stub a global in one test", () => { + vi.stubGlobal("indexedDB", { open: vi.fn() }); + + expect(indexedDB.open).toBeDefined(); +}); + +test("test setup restores stubbed globals before the next test", () => { + expect("indexedDB" in globalThis).toBe(false); +}); diff --git a/src/shared/test/setup.ts b/src/shared/test/setup.ts index 79eade2..b8062be 100644 --- a/src/shared/test/setup.ts +++ b/src/shared/test/setup.ts @@ -1,10 +1,23 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; -import { afterEach, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, vi } from "vitest"; + +import { server } from "./server"; + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); afterEach(() => { cleanup(); vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); sessionStorage.clear(); localStorage.clear(); + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); }); diff --git a/src/shared/ui/__tests__/admin-audio-player.test.tsx b/src/shared/ui/__tests__/admin-audio-player.test.tsx new file mode 100644 index 0000000..02f68df --- /dev/null +++ b/src/shared/ui/__tests__/admin-audio-player.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player"; +import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider"; + +let playSpy: ReturnType; +let pauseSpy: ReturnType; +let loadSpy: ReturnType; + +beforeEach(() => { + playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); + pauseSpy = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); + loadSpy = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined); +}); + +afterEach(() => { + playSpy.mockRestore(); + pauseSpy.mockRestore(); + loadSpy.mockRestore(); +}); + +test("AdminAudioPlayer wraps native audio with controls and no download or autoplay", () => { + render(); + + const audio = document.querySelector("audio"); + expect(audio).toHaveAttribute("src", "https://cdn.example.com/signed/audio.m4a?token=secret"); + expect(audio).not.toHaveAttribute("autoplay"); + expect(audio).toHaveAttribute("controlsList", "nodownload"); + expect(screen.getByRole("button", { name: "재생" })).toBeInTheDocument(); + expect(screen.getByRole("slider", { name: "재생 위치" })).toBeInTheDocument(); + expect(screen.getByRole("slider", { name: "볼륨" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument(); +}); + +test("AudioPlaybackProvider keeps only one player active by player id", () => { + render( + + + + , + ); + + const playButtons = screen.getAllByRole("button", { name: "재생" }); + const firstPlayButton = playButtons[0]; + const secondPlayButton = playButtons[1]; + if (firstPlayButton === undefined || secondPlayButton === undefined) { + throw new Error("expected two play buttons"); + } + + fireEvent.click(firstPlayButton); + fireEvent.click(secondPlayButton); + + expect(playSpy).toHaveBeenCalledTimes(2); + expect(pauseSpy).toHaveBeenCalled(); +}); + +test("AdminAudioPlayer supports keyboard play, seek, volume, speed, generic error, and manual retry only", () => { + render(); + + const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" }); + fireEvent.keyDown(player, { key: " " }); + fireEvent.change(screen.getByRole("slider", { name: "재생 위치" }), { target: { value: "12" } }); + fireEvent.change(screen.getByRole("slider", { name: "볼륨" }), { target: { value: "0.5" } }); + fireEvent.change(screen.getByRole("combobox", { name: "재생 속도" }), { target: { value: "1.5" } }); + const audio = document.querySelector("audio"); + if (!(audio instanceof HTMLAudioElement)) { + throw new Error("expected native audio element"); + } + fireEvent.error(audio); + + expect(playSpy).toHaveBeenCalledTimes(1); + expect(screen.getByRole("alert")).toHaveTextContent("오디오를 재생할 수 없습니다"); + fireEvent.click(screen.getByRole("button", { name: "오디오 다시 시도" })); + expect(loadSpy).toHaveBeenCalledTimes(1); + expect(playSpy).toHaveBeenCalledTimes(1); +}); + +test("AdminAudioPlayer ignores Enter and Space from descendant controls", () => { + render(); + + fireEvent.keyDown(screen.getByRole("combobox", { name: "재생 속도" }), { key: "Enter" }); + fireEvent.keyDown(screen.getByRole("button", { name: "재생" }), { key: " " }); + + expect(playSpy).not.toHaveBeenCalled(); +}); + +test("AudioPlaybackProvider and AdminAudioPlayer do not log or persist signed URLs", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const localStorageSpy = vi.spyOn(Storage.prototype, "setItem"); + const signedUrl = "https://cdn.example.com/signed/audio.m4a?token=secret"; + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "재생" })); + const audio = document.querySelector("audio"); + if (!(audio instanceof HTMLAudioElement)) { + throw new Error("expected native audio element"); + } + fireEvent.error(audio); + + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(localStorageSpy).not.toHaveBeenCalledWith(expect.any(String), expect.stringContaining(signedUrl)); + + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + localStorageSpy.mockRestore(); +}); diff --git a/src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx b/src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx new file mode 100644 index 0000000..6d32351 --- /dev/null +++ b/src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { expect, test, vi } from "vitest"; + +import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog"; + +test("ConfirmDeactivateDialog confirms deactivation with target and impact copy, not a switch", () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + + render( + , + ); + + expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveTextContent("사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "취소" })); + fireEvent.click(screen.getByRole("button", { name: "비활성화" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledTimes(1); +}); + +test("ConfirmDeactivateDialog traps focus and returns it to the trigger after cancel", async () => { + function Harness() { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} onConfirm={() => setOpen(false)} open={open} targetName="루나" /> + + ); + } + + render(); + const trigger = screen.getByRole("button", { name: "비활성화 열기" }); + trigger.focus(); + fireEvent.click(trigger); + const cancel = screen.getByRole("button", { name: "취소" }); + const confirm = screen.getByRole("button", { name: "비활성화" }); + await waitFor(() => expect(cancel).toHaveFocus()); + + fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab", shiftKey: true }); + expect(confirm).toHaveFocus(); + fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab" }); + expect(cancel).toHaveFocus(); + fireEvent.click(cancel); + + await waitFor(() => expect(trigger).toHaveFocus()); +}); diff --git a/src/shared/ui/__tests__/file-field.test.tsx b/src/shared/ui/__tests__/file-field.test.tsx new file mode 100644 index 0000000..2bdf5ca --- /dev/null +++ b/src/shared/ui/__tests__/file-field.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import { FileField } from "@/shared/ui/file-field"; + +test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => { + const onChange = vi.fn(); + const value = new File(["image"], "profile.png", { type: "image/png" }); + + render( + , + ); + + const input = screen.getByLabelText("대표 이미지"); + expect(input).toHaveAttribute("accept", "image/png"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다."); + expect(screen.getByText("profile.png")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument(); +}); + +test("FileField emits File or null and clear selection without owning upload policy", () => { + const onChange = vi.fn(); + const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" }); + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText("오디오"), { target: { files: [selected] } }); + expect(onChange).toHaveBeenCalledWith(selected); + + rerender(); + fireEvent.click(screen.getByRole("button", { name: "선택 취소" })); + expect(onChange).toHaveBeenCalledWith(null); +}); diff --git a/src/shared/ui/__tests__/file-media-dependency-boundary.test.ts b/src/shared/ui/__tests__/file-media-dependency-boundary.test.ts new file mode 100644 index 0000000..0f2013e --- /dev/null +++ b/src/shared/ui/__tests__/file-media-dependency-boundary.test.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; + +import { expect, test } from "vitest"; + +const sharedFileMediaFiles = [ + "src/shared/validation/file-validation.ts", + "src/shared/validation/audio-file-policy.ts", + "src/shared/validation/image-policy.ts", + "src/shared/lib/crop-image.ts", + "src/shared/ui/file-field.tsx", + "src/shared/ui/image-crop-dialog.tsx", + "src/shared/ui/upload-progress.tsx", + "src/shared/ui/admin-audio-player.tsx", + "src/shared/ui/audio-playback-provider.tsx", + "src/shared/ui/audio-playback-context.ts", + "src/shared/ui/use-audio-playback.ts", +] as const; + +test("shared file media primitives do not import endpoints, query cache, or domain DTOs", async () => { + const contents = await Promise.all(sharedFileMediaFiles.map((filePath) => readFile(filePath, "utf8"))); + + for (const content of contents) { + expect(content).not.toMatch(/@\/features\//); + expect(content).not.toMatch(/@tanstack\/react-query/); + expect(content).not.toMatch(/@\/shared\/api/); + expect(content).not.toMatch(/endpoint|DTO/iu); + } +}); diff --git a/src/shared/ui/__tests__/icon-only-action.test.tsx b/src/shared/ui/__tests__/icon-only-action.test.tsx new file mode 100644 index 0000000..a3d71d2 --- /dev/null +++ b/src/shared/ui/__tests__/icon-only-action.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; + +import { IconOnlyAction } from "@/shared/ui/icon-only-action"; + +describe("IconOnlyAction", () => { + test("has an accessible name and a tooltip without duplicating the name as description", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "새로고침" }); + const tooltip = screen.getByRole("tooltip"); + + expect(tooltip).toHaveTextContent("새로고침"); + expect(button).not.toHaveAccessibleDescription("새로고침"); + expect(button).not.toHaveAttribute("aria-describedby"); + expect(button).toHaveAttribute("type", "button"); + }); +}); diff --git a/src/shared/ui/__tests__/image-crop-dialog.test.tsx b/src/shared/ui/__tests__/image-crop-dialog.test.tsx new file mode 100644 index 0000000..8b82b86 --- /dev/null +++ b/src/shared/ui/__tests__/image-crop-dialog.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; +import type { CropRenderRequest } from "@/shared/lib/crop-image"; + +const image = { + file: new File(["image"], "profile.png", { type: "image/png" }), + height: 600, + previewUrl: "blob:profile", + width: 600, +}; + +test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => { + const onApply = vi.fn(); + const onCancel = vi.fn(); + const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" }))); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" })); + fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } }); + expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "초기화" })); + fireEvent.click(screen.getByRole("button", { name: "적용" })); + await screen.findByText("예상 결과 600 × 600px"); + + expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 })); + expect(onApply).toHaveBeenCalledWith(expect.any(File)); + fireEvent.click(screen.getByRole("button", { name: "취소" })); + expect(onCancel).toHaveBeenCalled(); +}); + +test("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => { + const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" }))); + + render(); + + const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" }); + fireEvent.keyDown(preview, { key: "ArrowRight" }); + fireEvent.keyDown(preview, { key: "+" }); + fireEvent.click(screen.getByRole("button", { name: "적용" })); + + expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument(); + expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 })); +}); + +test("ImageCropDialog supports free ratio output and pointer drag movement", async () => { + const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" }))); + + render(); + + const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" }); + fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 }); + fireEvent.pointerUp(preview, { pointerId: 1 }); + fireEvent.click(screen.getByRole("button", { name: "적용" })); + + expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument(); + expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 })); +}); + +test("ImageCropDialog renders nothing when closed", () => { + render(); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); diff --git a/src/shared/ui/__tests__/page-state.test.tsx b/src/shared/ui/__tests__/page-state.test.tsx new file mode 100644 index 0000000..bc96153 --- /dev/null +++ b/src/shared/ui/__tests__/page-state.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import { PageState } from "@/shared/ui/page-state"; + +test("PageState exposes accessible loading, empty, error, retry, and content states", () => { + const onRetry = vi.fn(); + const { rerender } = render(); + + expect(screen.getByRole("status")).toHaveTextContent("불러오는 중"); + + rerender(); + + expect(screen.getByRole("status")).toHaveTextContent("자료 없음"); + + rerender(); + + expect(screen.getByRole("alert")).toHaveTextContent("불러오지 못했습니다"); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(onRetry).toHaveBeenCalledTimes(1); + + rerender( + +

공유 콘텐츠

+
, + ); + + expect(screen.getByText("공유 콘텐츠")).toBeInTheDocument(); +}); diff --git a/src/shared/ui/__tests__/resource-pagination.test.tsx b/src/shared/ui/__tests__/resource-pagination.test.tsx new file mode 100644 index 0000000..e58e5d2 --- /dev/null +++ b/src/shared/ui/__tests__/resource-pagination.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import type { PageData } from "@/shared/api/pagination"; +import { ResourcePagination } from "@/shared/ui/resource-pagination"; + +const pageData: PageData = { + totalCount: 42, + page: 1, + size: 20, + hasNext: true, + items: [], +}; + +test("ResourcePagination uses PageData and real buttons for accessible page movement", () => { + const onPageChange = vi.fn(); + const onSizeChange = vi.fn(); + + render(); + + const previous = screen.getByRole("button", { name: "이전 페이지" }); + const next = screen.getByRole("button", { name: "다음 페이지" }); + + expect(previous.tagName).toBe("BUTTON"); + expect(next).toBeEnabled(); + expect(screen.getByText("총 42개 · 2페이지")).toBeInTheDocument(); + + fireEvent.click(previous); + fireEvent.click(next); + fireEvent.change(screen.getByLabelText("페이지 크기"), { target: { value: "50" } }); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 0); + expect(onPageChange).toHaveBeenNthCalledWith(2, 2); + expect(onSizeChange).toHaveBeenCalledWith(50); +}); + +test("ResourcePagination disables unavailable previous and next actions", () => { + render(); + + expect(screen.getByRole("button", { name: "이전 페이지" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled(); +}); diff --git a/src/shared/ui/__tests__/responsive-resource-list.test.tsx b/src/shared/ui/__tests__/responsive-resource-list.test.tsx new file mode 100644 index 0000000..f4c01c3 --- /dev/null +++ b/src/shared/ui/__tests__/responsive-resource-list.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test } from "vitest"; + +import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list"; + +test("ResponsiveResourceList renders only desktop and mobile slots without domain props", () => { + render( + 데스크톱 슬롯} + mobile={
  • 모바일 슬롯
} + />, + ); + + expect(screen.getByRole("region", { name: "공유 자료 목록" })).toBeInTheDocument(); + expect(screen.getByText("데스크톱 슬롯")).toBeInTheDocument(); + expect(screen.getByText("모바일 슬롯")).toBeInTheDocument(); +}); diff --git a/src/shared/ui/__tests__/search-toolbar.test.tsx b/src/shared/ui/__tests__/search-toolbar.test.tsx new file mode 100644 index 0000000..48e4a51 --- /dev/null +++ b/src/shared/ui/__tests__/search-toolbar.test.tsx @@ -0,0 +1,42 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { SearchToolbar } from "@/shared/ui/search-toolbar"; + +afterEach(() => { + vi.useRealTimers(); +}); + +test("SearchToolbar keeps search controlled, renders filters, and emits only a debounced generic query", () => { + vi.useFakeTimers(); + const onQueryChange = vi.fn(); + const onSearchChange = vi.fn(); + const { rerender } = render( + } + onQueryChange={onQueryChange} + onSearchChange={onSearchChange} + search="" + />, + ); + + fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } }); + + expect(onSearchChange).toHaveBeenCalledWith("루나"); + expect(screen.getByLabelText("상태 필터")).toBeInTheDocument(); + expect(onQueryChange).not.toHaveBeenCalled(); + + rerender( + } + onQueryChange={onQueryChange} + onSearchChange={onSearchChange} + search="루나" + />, + ); + act(() => vi.advanceTimersByTime(299)); + expect(onQueryChange).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(1)); + expect(onQueryChange).toHaveBeenCalledWith("루나"); +}); diff --git a/src/shared/ui/__tests__/status-badge.test.tsx b/src/shared/ui/__tests__/status-badge.test.tsx new file mode 100644 index 0000000..2e2ad6b --- /dev/null +++ b/src/shared/ui/__tests__/status-badge.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; + +import { StatusBadge } from "@/shared/ui/status-badge"; + +describe("StatusBadge", () => { + test("renders visible Korean text labels for every state", () => { + render( + <> + + + + , + ); + + expect(screen.getByLabelText("상태: 공개")).toHaveTextContent("공개"); + expect(screen.getByLabelText("상태: 예약")).toHaveTextContent("예약"); + expect(screen.getByLabelText("상태: 비활성")).toHaveTextContent("비활성"); + }); + + test("renders a domain label with optional icon and description without relying on color meaning", () => { + render( + 오늘 18:00 자동 전환} + icon={} + label="검수 대기" + tone="warning" + />, + ); + + const badge = screen.getByLabelText("상태: 검수 대기, 오늘 18:00 자동 전환"); + + expect(badge).toHaveTextContent("검수 대기"); + expect(badge).toHaveTextContent("오늘 18:00 자동 전환"); + }); +}); diff --git a/src/shared/ui/__tests__/unsaved-changes-guard.test.tsx b/src/shared/ui/__tests__/unsaved-changes-guard.test.tsx new file mode 100644 index 0000000..589b851 --- /dev/null +++ b/src/shared/ui/__tests__/unsaved-changes-guard.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard"; + +test("UnsavedChangesGuard blocks only dirty route leave and returns focus to the trigger on cancel", async () => { + const onLeave = vi.fn(); + const { rerender } = render( + + {(requestRouteLeave) => ( + + )} + , + ); + + const trigger = screen.getByRole("button", { name: "목록으로 이동" }); + fireEvent.click(trigger); + + expect(onLeave).not.toHaveBeenCalled(); + const dialog = screen.getByRole("alertdialog", { name: "이 화면을 떠나시겠습니까?" }); + expect(dialog).toBeInTheDocument(); + const cancel = screen.getByRole("button", { name: "계속 편집" }); + const leave = screen.getByRole("button", { name: "떠나기" }); + await waitFor(() => expect(cancel).toHaveFocus()); + + fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true }); + expect(leave).toHaveFocus(); + fireEvent.keyDown(dialog, { key: "Tab" }); + expect(cancel).toHaveFocus(); + + fireEvent.click(cancel); + expect(trigger).toHaveFocus(); + + rerender( + + {(requestRouteLeave) => ( + + )} + , + ); + fireEvent.click(screen.getByRole("button", { name: "목록으로 이동" })); + + expect(onLeave).toHaveBeenCalledTimes(1); +}); diff --git a/src/shared/ui/__tests__/upload-progress.test.tsx b/src/shared/ui/__tests__/upload-progress.test.tsx new file mode 100644 index 0000000..5784871 --- /dev/null +++ b/src/shared/ui/__tests__/upload-progress.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import { UploadProgress } from "@/shared/ui/upload-progress"; + +test("UploadProgress displays status and progress without owning an upload client", () => { + render(); + + expect(screen.getByRole("progressbar", { name: "업로드 진행률" })).toHaveAttribute("aria-valuenow", "45"); + expect(screen.getByText("voice.mp3")).toBeInTheDocument(); + expect(screen.getByText("업로드 중")).toBeInTheDocument(); +}); + +test("UploadProgress exposes cancel and retry callbacks only", () => { + const onCancel = vi.fn(); + const onRetry = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "업로드 취소" })); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledTimes(1); +}); diff --git a/src/shared/ui/admin-audio-player.tsx b/src/shared/ui/admin-audio-player.tsx new file mode 100644 index 0000000..20ed21a --- /dev/null +++ b/src/shared/ui/admin-audio-player.tsx @@ -0,0 +1,141 @@ +import { useRef, useState } from "react"; + +import { useAudioPlayback } from "./use-audio-playback"; + +export type AdminAudioPlayerProps = { + readonly playerId: string; + readonly src: string; + readonly title: string; +}; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) { + return "0:00"; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60).toString().padStart(2, "0"); + + return `${minutes}:${remainingSeconds}`; +} + +export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps) { + const audioRef = useRef(null); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [hasError, setHasError] = useState(false); + const { clearPlayer, requestPlay } = useAudioPlayback(playerId, audioRef); + + function play() { + const audio = audioRef.current; + if (audio === null) { + return; + } + setHasError(false); + requestPlay(); + void audio.play().catch(() => setHasError(true)); + } + + function pause() { + audioRef.current?.pause(); + clearPlayer(); + } + + function togglePlay() { + if (isPlaying) { + pause(); + return; + } + play(); + } + + function retry() { + setHasError(false); + audioRef.current?.load(); + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.currentTarget !== event.target) { + return; + } + + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + togglePlay(); + } + } + + function changeCurrentTime(nextTime: number) { + const audio = audioRef.current; + setCurrentTime(nextTime); + if (audio !== null) { + audio.currentTime = nextTime; + } + } + + function changeVolume(nextVolume: number) { + if (audioRef.current !== null) { + audioRef.current.volume = nextVolume; + } + } + + function changePlaybackRate(nextPlaybackRate: number) { + if (audioRef.current !== null) { + audioRef.current.playbackRate = nextPlaybackRate; + } + } + + return ( +
+
+ ); +} diff --git a/src/shared/ui/audio-playback-context.ts b/src/shared/ui/audio-playback-context.ts new file mode 100644 index 0000000..004ad36 --- /dev/null +++ b/src/shared/ui/audio-playback-context.ts @@ -0,0 +1,9 @@ +import { createContext } from "react"; + +export type AudioPlaybackContextValue = { + readonly activePlayerId: string | null; + readonly clearPlayer: (playerId: string) => void; + readonly requestPlay: (playerId: string) => void; +}; + +export const AudioPlaybackContext = createContext(null); diff --git a/src/shared/ui/audio-playback-provider.tsx b/src/shared/ui/audio-playback-provider.tsx new file mode 100644 index 0000000..9279cc2 --- /dev/null +++ b/src/shared/ui/audio-playback-provider.tsx @@ -0,0 +1,22 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; + +import { AudioPlaybackContext } from "./audio-playback-context"; + +export type AudioPlaybackProviderProps = { + readonly children: ReactNode; +}; + +export function AudioPlaybackProvider({ children }: AudioPlaybackProviderProps) { + const [activePlayerId, setActivePlayerId] = useState(null); + + function clearPlayer(playerId: string) { + setActivePlayerId((current) => (current === playerId ? null : current)); + } + + return ( + + {children} + + ); +} diff --git a/src/shared/ui/confirm-deactivate-dialog.tsx b/src/shared/ui/confirm-deactivate-dialog.tsx new file mode 100644 index 0000000..5196bd3 --- /dev/null +++ b/src/shared/ui/confirm-deactivate-dialog.tsx @@ -0,0 +1,38 @@ +import { useModalFocus } from "@/shared/ui/use-modal-focus"; + +export type ConfirmDeactivateDialogProps = { + readonly impactDescription: string; + readonly onCancel: () => void; + readonly onConfirm: () => void; + readonly open: boolean; + readonly targetName: string; +}; + +export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) { + const { dialogRef, trapFocus } = useModalFocus(open); + + if (!open) { + return null; + } + + const title = `${targetName} 비활성화 확인`; + + return ( +
+
+
+

{title}

+

{impactDescription}

+
+
+ + +
+
+
+ ); +} diff --git a/src/shared/ui/file-field.tsx b/src/shared/ui/file-field.tsx new file mode 100644 index 0000000..63572e6 --- /dev/null +++ b/src/shared/ui/file-field.tsx @@ -0,0 +1,48 @@ +import { useId, useRef } from "react"; + +export type FileFieldProps = { + readonly accept: string; + readonly acceptDescription: string; + readonly description?: string; + readonly error?: string; + readonly label: string; + readonly onChange: (file: File | null) => void; + readonly value: File | null; +}; + +export function FileField({ accept, acceptDescription, description, error, label, onChange, value }: FileFieldProps) { + const inputId = useId(); + const descriptionId = useId(); + const acceptId = useId(); + const errorId = useId(); + const inputRef = useRef(null); + const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" "); + + function handleChange(event: React.ChangeEvent) { + const files = event.currentTarget.files; + onChange(files === null ? null : files[0] ?? null); + } + + function clearSelection() { + if (inputRef.current !== null) { + inputRef.current.value = ""; + } + onChange(null); + } + + return ( +
+ + {description === undefined ? null :

{description}

} +

{acceptDescription}

+ +
+ {value === null ? "선택된 파일 없음" : value.name} + {value === null ? null : ( + + )} +
+ {error === undefined ? null : } +
+ ); +} diff --git a/src/shared/ui/icon-only-action.tsx b/src/shared/ui/icon-only-action.tsx new file mode 100644 index 0000000..815cb06 --- /dev/null +++ b/src/shared/ui/icon-only-action.tsx @@ -0,0 +1,24 @@ +import { useId } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +export type IconOnlyActionProps = Omit, "aria-label" | "children"> & { + readonly children: ReactNode; + readonly label: string; +}; + +export function IconOnlyAction({ children, className, label, type = "button", ...buttonProps }: IconOnlyActionProps) { + const tooltipId = useId(); + const classes = ["icon-only-action", className].filter(Boolean).join(" "); + + return ( + + + + {label} + + + ); +} diff --git a/src/shared/ui/image-crop-dialog.tsx b/src/shared/ui/image-crop-dialog.tsx new file mode 100644 index 0000000..3680eeb --- /dev/null +++ b/src/shared/ui/image-crop-dialog.tsx @@ -0,0 +1,153 @@ +import { useRef, useState } from "react"; + +import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image"; +import type { CropRenderRequest } from "@/shared/lib/crop-image"; +import { useModalFocus } from "@/shared/ui/use-modal-focus"; + +export type CropSourceImage = { + readonly file: File; + readonly height: number; + readonly previewUrl: string; + readonly width: number; +}; + +export type ImageCropPolicy = { + readonly aspect: number | "free"; + readonly maxWidth: number; + readonly noUpscale: boolean; +}; + +export type ImageCropDialogProps = { + readonly image: CropSourceImage; + readonly onApply: (file: File) => void; + readonly onCancel: () => void; + readonly open: boolean; + readonly policy: ImageCropPolicy; + readonly renderCrop?: (request: CropRenderRequest) => Promise; +}; + +const MOVE_STEP = 10; +const ZOOM_STEP = 0.1; + +export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) { + const [offsetX, setOffsetX] = useState(0); + const [offsetY, setOffsetY] = useState(0); + const [zoom, setZoom] = useState(1); + const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null); + const { dialogRef, trapFocus } = useModalFocus(open); + const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width }); + + if (!open) { + return null; + } + + function resetCrop() { + setOffsetX(0); + setOffsetY(0); + setZoom(1); + } + + function move(deltaX: number, deltaY: number) { + setOffsetX((current) => current + deltaX); + setOffsetY((current) => current + deltaY); + } + + function changeZoom(nextZoom: number) { + setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1))))); + } + + function handleKeyDown(event: React.KeyboardEvent) { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + move(0, MOVE_STEP); + return; + case "ArrowLeft": + event.preventDefault(); + move(-MOVE_STEP, 0); + return; + case "ArrowRight": + event.preventDefault(); + move(MOVE_STEP, 0); + return; + case "ArrowUp": + event.preventDefault(); + move(0, -MOVE_STEP); + return; + case "+": + event.preventDefault(); + changeZoom(zoom + ZOOM_STEP); + return; + case "-": + event.preventDefault(); + changeZoom(zoom - ZOOM_STEP); + return; + default: + } + } + + function startDrag(event: React.PointerEvent) { + dragPointRef.current = { x: event.clientX, y: event.clientY }; + event.currentTarget.setPointerCapture?.(event.pointerId); + } + + function drag(event: React.PointerEvent) { + const dragPoint = dragPointRef.current; + if (dragPoint === null) { + return; + } + + move(event.clientX - dragPoint.x, event.clientY - dragPoint.y); + dragPointRef.current = { x: event.clientX, y: event.clientY }; + } + + function stopDrag() { + dragPointRef.current = null; + } + + async function applyCrop() { + const file = await renderCrop({ + aspect: policy.aspect, + file: image.file, + offsetX, + offsetY, + outputHeight: outputSize.height, + outputWidth: outputSize.width, + previewUrl: image.previewUrl, + sourceHeight: image.height, + sourceWidth: image.width, + zoom, + }); + onApply(file); + } + + return ( +
+
+
+

이미지 crop

+

버튼, 범위 입력, 방향키로 위치와 확대를 조정한 뒤 적용합니다.

+
+
+ 선택한 이미지 미리보기 +
+

예상 결과 {outputSize.width} × {outputSize.height}px

+
+ + + + +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/shared/ui/page-state.tsx b/src/shared/ui/page-state.tsx new file mode 100644 index 0000000..495c9b8 --- /dev/null +++ b/src/shared/ui/page-state.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +type LoadingPageStateProps = { + readonly description?: string; + readonly state: "loading"; + readonly title: string; +}; + +type EmptyPageStateProps = { + readonly description?: string; + readonly state: "empty"; + readonly title: string; +}; + +type ErrorPageStateProps = { + readonly description?: string; + readonly onRetry?: () => void; + readonly state: "error"; + readonly title: string; +}; + +type ContentPageStateProps = { + readonly children: ReactNode; + readonly state: "content"; +}; + +export type PageStateProps = LoadingPageStateProps | EmptyPageStateProps | ErrorPageStateProps | ContentPageStateProps; + +function assertNever(value: never): never { + throw new Error(`Unhandled page state: ${String(value)}`); +} + +export function PageState(props: PageStateProps) { + switch (props.state) { + case "content": + return <>{props.children}; + case "loading": + return ( +
+

{props.title}

+ {props.description === undefined ? null :

{props.description}

} +
+ ); + case "empty": + return ( +
+

{props.title}

+ {props.description === undefined ? null :

{props.description}

} +
+ ); + case "error": + return ( +
+

{props.title}

+ {props.description === undefined ? null :

{props.description}

} + {props.onRetry === undefined ? null : ( + + )} +
+ ); + default: + return assertNever(props); + } +} diff --git a/src/shared/ui/resource-pagination.tsx b/src/shared/ui/resource-pagination.tsx new file mode 100644 index 0000000..615b239 --- /dev/null +++ b/src/shared/ui/resource-pagination.tsx @@ -0,0 +1,32 @@ +import type { PageData } from "@/shared/api/pagination"; + +export type ResourcePaginationProps = { + readonly data: PageData; + readonly onPageChange: (page: number) => void; + readonly onSizeChange: (size: number) => void; + readonly sizeOptions?: readonly number[]; +}; + +export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptions = [20, 50] }: ResourcePaginationProps) { + return ( + + ); +} diff --git a/src/shared/ui/responsive-resource-list.tsx b/src/shared/ui/responsive-resource-list.tsx new file mode 100644 index 0000000..e5c39c4 --- /dev/null +++ b/src/shared/ui/responsive-resource-list.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +export type ResponsiveResourceListProps = { + readonly ariaLabel: string; + readonly desktop: ReactNode; + readonly mobile: ReactNode; +}; + +export function ResponsiveResourceList({ ariaLabel, desktop, mobile }: ResponsiveResourceListProps) { + return ( +
+
{desktop}
+
{mobile}
+
+ ); +} diff --git a/src/shared/ui/search-toolbar.tsx b/src/shared/ui/search-toolbar.tsx new file mode 100644 index 0000000..7a098b8 --- /dev/null +++ b/src/shared/ui/search-toolbar.tsx @@ -0,0 +1,37 @@ +import { useEffect, useId, useRef } from "react"; +import type { ReactNode } from "react"; + +export type SearchToolbarProps = { + readonly filters?: ReactNode; + readonly onQueryChange: (query: string) => void; + readonly onSearchChange: (search: string) => void; + readonly search: string; +}; + +export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }: SearchToolbarProps) { + const searchId = useId(); + const didMountRef = useRef(false); + + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; + return undefined; + } + + const timeoutId = window.setTimeout(() => onQueryChange(search), 300); + + return () => window.clearTimeout(timeoutId); + }, [onQueryChange, search]); + + return ( +
+
+ + onSearchChange(event.currentTarget.value)} type="search" value={search} /> +
+ {filters === undefined ? null :
{filters}
} +
+ ); +} diff --git a/src/shared/ui/status-badge.tsx b/src/shared/ui/status-badge.tsx new file mode 100644 index 0000000..8d71b9b --- /dev/null +++ b/src/shared/ui/status-badge.tsx @@ -0,0 +1,91 @@ +import { isValidElement } from "react"; +import type { ReactNode } from "react"; + +const STATUS_BADGE = { + INACTIVE: { + className: "status-badge status-badge--inactive", + label: "비활성", + tone: "inactive", + }, + OPEN: { + className: "status-badge status-badge--success", + label: "공개", + tone: "success", + }, + SCHEDULED: { + className: "status-badge status-badge--warning", + label: "예약", + tone: "warning", + }, +} as const; + +export type StatusBadgeStatus = keyof typeof STATUS_BADGE; +export type StatusBadgeTone = "inactive" | "success" | "warning"; + +type PresetStatusBadgeProps = { + readonly status: StatusBadgeStatus; + readonly description?: never; + readonly icon?: never; + readonly label?: never; + readonly tone?: never; +}; + +type DomainStatusBadgeProps = { + readonly description?: ReactNode; + readonly icon?: ReactNode; + readonly label: string; + readonly status?: never; + readonly tone: StatusBadgeTone; +}; + +export type StatusBadgeProps = PresetStatusBadgeProps | DomainStatusBadgeProps; + +function getNodeText(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + + if (Array.isArray(node)) { + return node.map(getNodeText).join(""); + } + + if (isValidElement<{ readonly children?: ReactNode }>(node)) { + return getNodeText(node.props.children); + } + + return ""; +} + +function getBadge(props: StatusBadgeProps): { + readonly className: string; + readonly description: ReactNode; + readonly label: string; + readonly icon: ReactNode; +} { + if (props.status !== undefined) { + const badge = STATUS_BADGE[props.status]; + + return { className: badge.className, description: null, icon: null, label: badge.label }; + } + + return { + className: `status-badge status-badge--${props.tone}`, + description: props.description ?? null, + icon: props.icon ?? null, + label: props.label, + }; +} + +export function StatusBadge(props: StatusBadgeProps) { + const badge = getBadge(props); + const descriptionText = getNodeText(badge.description); + const ariaLabel = descriptionText.length > 0 ? `상태: ${badge.label}, ${descriptionText}` : `상태: ${badge.label}`; + + return ( + + {badge.icon ?? + ); +} diff --git a/src/shared/ui/unsaved-changes-guard.tsx b/src/shared/ui/unsaved-changes-guard.tsx new file mode 100644 index 0000000..1cb5843 --- /dev/null +++ b/src/shared/ui/unsaved-changes-guard.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; + +import { useModalFocus } from "@/shared/ui/use-modal-focus"; + +export type RequestRouteLeave = (trigger: HTMLElement, leaveRoute: () => void) => void; + +export type UnsavedChangesGuardProps = { + readonly children: (requestRouteLeave: RequestRouteLeave) => ReactNode; + readonly dirty: boolean; + readonly impactDescription: string; + readonly title: string; +}; + +type PendingRouteLeave = { + readonly leaveRoute: () => void; + readonly trigger: HTMLElement; +}; + +export function UnsavedChangesGuard({ children, dirty, impactDescription, title }: UnsavedChangesGuardProps) { + const [pendingRouteLeave, setPendingRouteLeave] = useState(null); + const { dialogRef, trapFocus } = useModalFocus(pendingRouteLeave !== null); + + function closeDialog() { + const trigger = pendingRouteLeave?.trigger; + setPendingRouteLeave(null); + trigger?.focus(); + } + + function confirmLeave() { + const leaveRoute = pendingRouteLeave?.leaveRoute; + setPendingRouteLeave(null); + leaveRoute?.(); + } + + function requestRouteLeave(trigger: HTMLElement, leaveRoute: () => void) { + if (!dirty) { + leaveRoute(); + return; + } + + setPendingRouteLeave({ leaveRoute, trigger }); + } + + return ( + <> + {children(requestRouteLeave)} + {dirty && pendingRouteLeave !== null ? ( +
+
+
+

{title}

+

{impactDescription}

+
+
+ + +
+
+
+ ) : null} + + ); +} diff --git a/src/shared/ui/upload-progress.tsx b/src/shared/ui/upload-progress.tsx new file mode 100644 index 0000000..8676b50 --- /dev/null +++ b/src/shared/ui/upload-progress.tsx @@ -0,0 +1,39 @@ +const STATUS_LABEL = { + canceled: "취소됨", + error: "업로드 실패", + idle: "대기 중", + success: "업로드 완료", + uploading: "업로드 중", +} as const; + +export type UploadProgressStatus = keyof typeof STATUS_LABEL; + +export type UploadProgressProps = { + readonly fileName?: string; + readonly onCancel?: () => void; + readonly onRetry?: () => void; + readonly progress: number; + readonly status: UploadProgressStatus; +}; + +export function UploadProgress({ fileName, onCancel, onRetry, progress, status }: UploadProgressProps) { + const safeProgress = Math.min(100, Math.max(0, Math.round(progress))); + + return ( +
+
+
+ {fileName === undefined ? null :

{fileName}

} +

{STATUS_LABEL[status]}

+
+
+ {onCancel === undefined ? null : } + {onRetry === undefined ? null : } +
+
+
+
+
+
+ ); +} diff --git a/src/shared/ui/use-audio-playback.ts b/src/shared/ui/use-audio-playback.ts new file mode 100644 index 0000000..aea0f4d --- /dev/null +++ b/src/shared/ui/use-audio-playback.ts @@ -0,0 +1,19 @@ +import { useContext, useEffect } from "react"; +import type { RefObject } from "react"; + +import { AudioPlaybackContext } from "./audio-playback-context"; + +export function useAudioPlayback(playerId: string, audioRef: RefObject) { + const context = useContext(AudioPlaybackContext); + + useEffect(() => { + if (context?.activePlayerId !== null && context?.activePlayerId !== undefined && context.activePlayerId !== playerId) { + audioRef.current?.pause(); + } + }, [audioRef, context?.activePlayerId, playerId]); + + return { + clearPlayer: () => context?.clearPlayer(playerId), + requestPlay: () => context?.requestPlay(playerId), + }; +} diff --git a/src/shared/ui/use-modal-focus.ts b/src/shared/ui/use-modal-focus.ts new file mode 100644 index 0000000..88d8d02 --- /dev/null +++ b/src/shared/ui/use-modal-focus.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react"; + +const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"; + +export function useModalFocus(open: boolean) { + const dialogRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) { + return undefined; + } + + triggerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusableElements = Array.from(dialogRef.current?.querySelectorAll(focusableSelector) ?? []); + focusableElements[0]?.focus(); + + return () => { + triggerRef.current?.focus(); + triggerRef.current = null; + }; + }, [open]); + + function trapFocus(event: React.KeyboardEvent) { + if (event.key !== "Tab") { + return; + } + + const focusableElements = Array.from(dialogRef.current?.querySelectorAll(focusableSelector) ?? []); + const firstElement = focusableElements[0]; + const lastElement = focusableElements.at(-1); + + if (firstElement === undefined || lastElement === undefined) { + return; + } + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + return; + } + + if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + + return { dialogRef, trapFocus }; +} diff --git a/src/shared/validation/audio-file-policy.ts b/src/shared/validation/audio-file-policy.ts new file mode 100644 index 0000000..d44600d --- /dev/null +++ b/src/shared/validation/audio-file-policy.ts @@ -0,0 +1,31 @@ +import { getFileExtension, validateFile } from "./file-validation"; +import type { FileValidationResult } from "./file-validation"; + +export const AUDIO_FILE_POLICY = { + allowedExtensions: [".mp3", ".aac", ".m4a"], + allowedMimeTypes: ["audio/mpeg", "audio/aac", "audio/mp4", "audio/x-m4a"], + maxBytes: 1_024_000_000, +} as const; + +const allowedMimeByExtension = { + ".aac": ["audio/aac"], + ".m4a": ["audio/mp4", "audio/x-m4a"], + ".mp3": ["audio/mpeg"], +} satisfies Record; + +export type AudioFileValidationResult = FileValidationResult | { readonly ok: false; readonly reason: "mimeExtensionCombination" }; + +export function validateAudioFile(file: File): AudioFileValidationResult { + const baseResult = validateFile(file, AUDIO_FILE_POLICY); + if (!baseResult.ok) { + return baseResult; + } + + const extension = getFileExtension(file.name); + const allowedMimeTypes = allowedMimeByExtension[extension as keyof typeof allowedMimeByExtension]; + if (allowedMimeTypes === undefined || !allowedMimeTypes.includes(file.type)) { + return { ok: false, reason: "mimeExtensionCombination" }; + } + + return { ok: true }; +} diff --git a/src/shared/validation/file-media-policy.test.ts b/src/shared/validation/file-media-policy.test.ts new file mode 100644 index 0000000..0910608 --- /dev/null +++ b/src/shared/validation/file-media-policy.test.ts @@ -0,0 +1,93 @@ +import { expect, test, vi } from "vitest"; + +import { createImagePolicy, IMAGE_MAX_BYTES } from "@/shared/validation/image-policy"; +import { AUDIO_FILE_POLICY, validateAudioFile } from "@/shared/validation/audio-file-policy"; +import { validateFile } from "@/shared/validation/file-validation"; + +function fileWithSize(name: string, type: string, size: number): File { + const file = new File(["x"], name, { type }); + Object.defineProperty(file, "size", { value: size }); + + return file; +} + +test("validateFile checks injected extension, MIME, and maxBytes together", () => { + const policy = { + allowedExtensions: [".png"] as const, + allowedMimeTypes: ["image/png"] as const, + maxBytes: 10, + }; + + expect(validateFile(fileWithSize("cover.png", "image/png", 10), policy)).toEqual({ ok: true }); + expect(validateFile(fileWithSize("cover.jpg", "image/png", 10), policy)).toEqual({ ok: false, reason: "extension" }); + expect(validateFile(fileWithSize("cover.png", "image/jpeg", 10), policy)).toEqual({ ok: false, reason: "mime" }); + expect(validateFile(fileWithSize("cover.png", "image/png", 11), policy)).toEqual({ ok: false, reason: "size" }); +}); + +test("validateFile lets callers inject a 10MB byte boundary without owning image domain policy", () => { + const tenMegabytes = 10 * 1024 * 1024; + const policy = { + allowedExtensions: [".jpg"] as const, + allowedMimeTypes: ["image/jpeg"] as const, + maxBytes: tenMegabytes, + }; + + expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes), policy)).toEqual({ ok: true }); + expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes + 1), policy)).toEqual({ ok: false, reason: "size" }); +}); + +test("validateAudioFile accepts MP3, AAC, M4A including audio/x-m4a at 1,024,000,000 bytes", () => { + expect(AUDIO_FILE_POLICY.maxBytes).toBe(1_024_000_000); + expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_000))).toEqual({ ok: true }); + expect(validateAudioFile(fileWithSize("voice.aac", "audio/aac", 1_024_000_000))).toEqual({ ok: true }); + expect(validateAudioFile(fileWithSize("voice.m4a", "audio/mp4", 1_024_000_000))).toEqual({ ok: true }); + expect(validateAudioFile(fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000))).toEqual({ ok: true }); +}); + +test("validateAudioFile rejects WAV, oversized files, and audio/x-m4a without .m4a without sniffing codecs", async () => { + const wav = fileWithSize("voice.wav", "audio/wav", 10); + const sniff = vi.spyOn(wav, "arrayBuffer"); + + expect(validateAudioFile(wav)).toEqual({ ok: false, reason: "extension" }); + expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_001))).toEqual({ ok: false, reason: "size" }); + expect(validateAudioFile(fileWithSize("voice.aac", "audio/x-m4a", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" }); + expect(sniff).not.toHaveBeenCalled(); +}); + +test("validateAudioFile rejects mismatched canonical MIME and extension combinations", () => { + expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mp4", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" }); + expect(validateAudioFile(fileWithSize("voice.aac", "audio/mpeg", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" }); + expect(validateAudioFile(fileWithSize("voice.m4a", "audio/aac", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" }); +}); + +test("createImagePolicy records only a domain-neutral crop contract", () => { + expect(createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true })).toEqual({ + aspect: 1, + cropRequired: true, + maxBytes: 10_485_760, + maxWidth: 800, + noUpscale: true, + }); +}); + +test("createImagePolicy records the confirmed 10MiB image byte boundary", () => { + const policy = createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true }); + + expect(IMAGE_MAX_BYTES).toBe(10_485_760); + expect(policy.maxBytes).toBe(10_485_760); + expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_759), { + allowedExtensions: [".jpg"] as const, + allowedMimeTypes: ["image/jpeg"] as const, + maxBytes: policy.maxBytes, + })).toEqual({ ok: true }); + expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_760), { + allowedExtensions: [".jpg"] as const, + allowedMimeTypes: ["image/jpeg"] as const, + maxBytes: policy.maxBytes, + })).toEqual({ ok: true }); + expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_761), { + allowedExtensions: [".jpg"] as const, + allowedMimeTypes: ["image/jpeg"] as const, + maxBytes: policy.maxBytes, + })).toEqual({ ok: false, reason: "size" }); +}); diff --git a/src/shared/validation/file-validation.ts b/src/shared/validation/file-validation.ts new file mode 100644 index 0000000..cb527f1 --- /dev/null +++ b/src/shared/validation/file-validation.ts @@ -0,0 +1,31 @@ +export type FileValidationPolicy = { + readonly allowedExtensions: readonly string[]; + readonly allowedMimeTypes: readonly string[]; + readonly maxBytes: number; +}; + +export type FileValidationResult = + | { readonly ok: true } + | { readonly ok: false; readonly reason: "extension" | "mime" | "size" }; + +export function getFileExtension(fileName: string): string { + const dotIndex = fileName.lastIndexOf("."); + + return dotIndex < 0 ? "" : fileName.slice(dotIndex).toLowerCase(); +} + +export function validateFile(file: File, policy: FileValidationPolicy): FileValidationResult { + if (file.size > policy.maxBytes) { + return { ok: false, reason: "size" }; + } + + if (!policy.allowedExtensions.includes(getFileExtension(file.name))) { + return { ok: false, reason: "extension" }; + } + + if (!policy.allowedMimeTypes.includes(file.type)) { + return { ok: false, reason: "mime" }; + } + + return { ok: true }; +} diff --git a/src/shared/validation/image-policy.ts b/src/shared/validation/image-policy.ts new file mode 100644 index 0000000..926edc1 --- /dev/null +++ b/src/shared/validation/image-policy.ts @@ -0,0 +1,15 @@ +export type ImageAspect = number | "free"; + +export const IMAGE_MAX_BYTES = 10_485_760; + +export type ImagePolicy = { + readonly aspect: ImageAspect; + readonly cropRequired: boolean; + readonly maxBytes: number; + readonly maxWidth: number; + readonly noUpscale: boolean; +}; + +export function createImagePolicy(policy: Omit): ImagePolicy { + return { ...policy, maxBytes: IMAGE_MAX_BYTES }; +} diff --git a/src/styles/__tests__/design-system.test.ts b/src/styles/__tests__/design-system.test.ts new file mode 100644 index 0000000..f865bb4 --- /dev/null +++ b/src/styles/__tests__/design-system.test.ts @@ -0,0 +1,165 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const rootDir = process.cwd(); + +function projectFile(path: string) { + const absolutePath = join(rootDir, path); + + expect(existsSync(absolutePath)).toBe(true); + + return readFileSync(absolutePath, "utf8"); +} + +function cssSource() { + return projectFile("src/styles/globals.css"); +} + +function cssVariable(css: string, name: string) { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const matches = [...css.matchAll(new RegExp(`(?:^|[\\s{])--${escapedName}:\\s*([^;]+);`, "gm"))]; + const value = matches.at(-1)?.[1]?.trim(); + + if (!value) { + throw new Error(`Missing --${name}`); + } + + return resolveCssVariable(css, value).toUpperCase(); +} + +function resolveCssVariable(css: string, value: string): string { + const variableName = /^var\(--([^)]+)\)$/.exec(value.trim())?.[1]; + + if (!variableName) { + return value; + } + + return cssVariable(css, variableName); +} + +function relativeLuminance(channel: number) { + const normalized = channel / 255; + + if (normalized <= 0.03928) { + return normalized / 12.92; + } + + return ((normalized + 0.055) / 1.055) ** 2.4; +} + +function hexToRgb(hex: string) { + const value = hex.replace("#", ""); + + return { + blue: Number.parseInt(value.slice(4, 6), 16), + green: Number.parseInt(value.slice(2, 4), 16), + red: Number.parseInt(value.slice(0, 2), 16), + }; +} + +function contrastRatio(foreground: string, background: string) { + const fg = hexToRgb(foreground); + const bg = hexToRgb(background); + const fgLuminance = 0.2126 * relativeLuminance(fg.red) + 0.7152 * relativeLuminance(fg.green) + 0.0722 * relativeLuminance(fg.blue); + const bgLuminance = 0.2126 * relativeLuminance(bg.red) + 0.7152 * relativeLuminance(bg.green) + 0.0722 * relativeLuminance(bg.blue); + const lighter = Math.max(fgLuminance, bgLuminance); + const darker = Math.min(fgLuminance, bgLuminance); + + return (lighter + 0.05) / (darker + 0.05); +} + +describe("Task 1.1 design system tokens", () => { + test("wires PRD brand tokens through CSS variable mode", () => { + const css = cssSource(); + const componentsJson = projectFile("components.json"); + const main = projectFile("src/main.tsx"); + const design = projectFile("DESIGN.md"); + + expect(main).toContain('import "@/styles/globals.css";'); + expect(componentsJson).toContain('"cssVariables": true'); + expect(componentsJson).toContain('"css": "src/styles/globals.css"'); + expect(css).toContain("@import \"tailwindcss\""); + expect(cssVariable(css, "color-brand-500")).toBe("#00BDF7"); + expect(cssVariable(css, "primary")).toBe("#00BDF7"); + expect(cssVariable(css, "primary-foreground")).toBe("#062B36"); + expect(cssVariable(css, "button-bg")).toBe("#00BDF7"); + expect(design).toContain("--color-brand-500"); + expect(design).toContain("--primary"); + }); + + test("keeps required token contrast and blocks white text on primary", () => { + const css = cssSource(); + const primary = cssVariable(css, "color-brand-500"); + const primaryForeground = cssVariable(css, "color-primary-foreground"); + const background = cssVariable(css, "background"); + const card = cssVariable(css, "color-card"); + const info = cssVariable(css, "info"); + const input = cssVariable(css, "color-input"); + const ring = cssVariable(css, "color-brand-800"); + + expect(contrastRatio(primaryForeground, primary)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio("#FFFFFF", primary)).toBeLessThan(4.5); + expect(contrastRatio(info, background)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(input, card)).toBeGreaterThanOrEqual(3); + expect(contrastRatio(ring, card)).toBeGreaterThanOrEqual(3); + }); + + test("keeps core foreground contrast against page and card surfaces", () => { + const css = cssSource(); + const foreground = cssVariable(css, "foreground"); + const background = cssVariable(css, "background"); + const card = cssVariable(css, "card"); + + expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(foreground, card)).toBeGreaterThanOrEqual(4.5); + }); + + test("keeps the theme light-only", () => { + const source = [ + projectFile("src/main.tsx"), + projectFile("src/app/App.tsx"), + cssSource(), + projectFile("components.json"), + projectFile("package.json"), + ].join("\n"); + + expect(source).not.toMatch(/\.dark\b/); + expect(source).not.toContain("ThemeProvider"); + expect(source).not.toContain("theme toggle"); + expect(source).not.toContain("next-themes"); + expect(source).not.toContain("prefers-color-scheme"); + }); + + test("defines Korean admin base styles and accessibility primitives", () => { + const css = cssSource(); + + expect(cssVariable(css, "font-sans")).toBe('PRETENDARD, "NOTO SANS KR", "APPLE SD GOTHIC NEO", SYSTEM-UI, SANS-SERIF'); + expect(css).toContain("--target-control-min: 2.75rem"); + expect(css).toContain("--focus-ring-width: 0.125rem"); + expect(css).toContain("--z-sticky: 10"); + expect(css).toContain("--z-navigation: 20"); + expect(css).toContain("--z-popover: 30"); + expect(css).toContain("--z-overlay: 40"); + expect(css).toContain("--z-modal: 50"); + expect(css).toMatch(/input,\s*select,\s*textarea\s*{[^}]*font-size:\s*max\(1rem, var\(--font-size-body\)\)/s); + expect(css).toMatch(/button,\s*\[role="button"\],\s*input,\s*select,\s*textarea\s*{[^}]*min-block-size:\s*var\(--target-control-min\)/s); + expect(css).toContain(":focus-visible"); + expect(css).toContain("@media (prefers-reduced-motion: reduce)"); + expect(css).toContain(".icon-only-action"); + expect(css).toContain("border: 1px solid var(--input)"); + }); + + test("exposes declared semantic status and link tokens to Tailwind", () => { + const css = cssSource(); + + expect(css).toContain("--color-success: var(--success)"); + expect(css).toContain("--color-success-surface: var(--success-surface)"); + expect(css).toContain("--color-warning: var(--warning)"); + expect(css).toContain("--color-warning-surface: var(--warning-surface)"); + expect(css).toContain("--color-inactive: var(--inactive)"); + expect(css).toContain("--color-inactive-surface: var(--inactive-surface)"); + expect(css).toContain("--color-link: var(--link)"); + expect(css).toContain("--color-link-hover: var(--link-hover)"); + expect(css).toContain("--color-info: var(--info)"); + }); +}); diff --git a/src/styles/globals.css b/src/styles/globals.css new file mode 100644 index 0000000..209bfb9 --- /dev/null +++ b/src/styles/globals.css @@ -0,0 +1,289 @@ +@import "tailwindcss"; + +@theme inline { + --font-sans: var(--font-sans); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-success: var(--success); + --color-success-surface: var(--success-surface); + --color-warning: var(--warning); + --color-warning-surface: var(--warning-surface); + --color-inactive: var(--inactive); + --color-inactive-surface: var(--inactive-surface); + --color-link: var(--link); + --color-link-hover: var(--link-hover); + --color-info: var(--info); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --z-sticky: var(--z-sticky); + --z-navigation: var(--z-navigation); + --z-popover: var(--z-popover); + --z-overlay: var(--z-overlay); + --z-modal: var(--z-modal); +} + +:root { + --color-brand-50: #F0FBFF; + --color-brand-100: #D9F6FF; + --color-brand-200: #B5EEFF; + --color-brand-300: #7CE2FF; + --color-brand-400: #36D1FF; + --color-brand-500: #00BDF7; + --color-brand-600: #00A9DE; + --color-brand-700: #009DCE; + --color-brand-800: #007EA8; + --color-brand-900: #086789; + --color-brand-950: #063747; + --color-background: #F6FBFD; + --color-card: #FFFFFF; + --color-foreground: #102A33; + --color-muted: #E9F4F7; + --color-muted-foreground: #425F69; + --color-secondary: #E1F5FA; + --color-secondary-foreground: #123E4B; + --color-accent: #D9F6FF; + --color-accent-foreground: #0C566F; + --color-border: #D5E8EE; + --color-input: #577581; + --color-primary-foreground: #062B36; + --color-success: #167347; + --color-success-surface: #EAF8F0; + --color-warning: #9A5B00; + --color-warning-surface: #FFF7E6; + --color-destructive: #B42318; + --color-destructive-surface: #FEF0EE; + --color-inactive: #52636A; + --color-inactive-surface: #EEF3F5; + --background: var(--color-background); + --foreground: var(--color-foreground); + --card: var(--color-card); + --card-foreground: var(--color-foreground); + --popover: var(--color-card); + --popover-foreground: var(--color-foreground); + --primary: var(--color-brand-500); + --primary-hover: var(--color-brand-600); + --primary-active: var(--color-brand-700); + --primary-foreground: var(--color-primary-foreground); + --secondary: var(--color-secondary); + --secondary-foreground: var(--color-secondary-foreground); + --muted: var(--color-muted); + --muted-foreground: var(--color-muted-foreground); + --accent: var(--color-accent); + --accent-foreground: var(--color-accent-foreground); + --destructive: var(--color-destructive); + --border: var(--color-border); + --input: var(--color-input); + --ring: var(--color-brand-800); + --link: var(--color-brand-800); + --link-hover: var(--color-brand-900); + --info: var(--color-brand-900); + --success: var(--color-success); + --success-surface: var(--color-success-surface); + --warning: var(--color-warning); + --warning-surface: var(--color-warning-surface); + --inactive: var(--color-inactive); + --inactive-surface: var(--color-inactive-surface); + --font-sans: Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", system-ui, sans-serif; + --font-size-page-title: 1.5rem; + --font-size-section-title: 1.25rem; + --font-size-body: 0.875rem; + --font-size-small: 0.8125rem; + --font-size-caption: 0.75rem; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius: var(--radius-md); + --target-control-min: 2.75rem; + --focus-ring-width: 0.125rem; + --duration-micro: 150ms; + --z-sticky: 10; + --z-navigation: 20; + --z-popover: 30; + --z-overlay: 40; + --z-modal: 50; + --button-bg: var(--primary); + --button-bg-hover: var(--primary-hover); + --button-bg-active: var(--primary-active); + --button-fg: var(--primary-foreground); + --button-radius: var(--radius-md); + --button-border: var(--input); + --badge-radius: var(--radius-sm); +} + +* { + box-sizing: border-box; +} + +html { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-size: 100%; +} + +body { + min-block-size: 100dvb; + margin: 0; + background: var(--background); + color: var(--foreground); + font-size: var(--font-size-body); + line-height: 1.5; + text-rendering: optimizeLegibility; +} + +button, +[role="button"], +input, +select, +textarea { + min-block-size: var(--target-control-min); +} + +input, +select, +textarea { + font: inherit; + font-size: max(1rem, var(--font-size-body)); +} + +button { + font: inherit; +} + +:focus-visible { + outline: var(--focus-ring-width) solid var(--ring); + outline-offset: 0.125rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + border: 1px solid currentColor; + border-radius: var(--badge-radius); + padding: var(--space-1) var(--space-2); + font-size: var(--font-size-caption); + font-weight: 700; + line-height: 1.4; +} + +.status-badge__dot { + inline-size: 0.5rem; + block-size: 0.5rem; + border-radius: 999px; + background: currentColor; +} + +.status-badge--success { + background: var(--success-surface); + color: var(--success); +} + +.status-badge--warning { + background: var(--warning-surface); + color: var(--warning); +} + +.status-badge--inactive { + background: var(--inactive-surface); + color: var(--inactive); +} + +.icon-only-action-wrap { + position: relative; + display: inline-flex; +} + +.icon-only-action { + display: inline-grid; + place-items: center; + min-inline-size: var(--target-control-min); + border: 1px solid var(--input); + border-radius: var(--button-radius); + background: var(--card); + color: var(--foreground); + cursor: pointer; + transition: background-color var(--duration-micro) ease-out, color var(--duration-micro) ease-out, transform var(--duration-micro) ease-out; +} + +.icon-only-action:hover { + background: var(--accent); + color: var(--accent-foreground); +} + +.icon-only-action:active { + transform: translateY(1px); +} + +.icon-only-action:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.icon-only-action-tooltip { + position: absolute; + inset-block-end: calc(100% + var(--space-1)); + inset-inline-start: 50%; + z-index: var(--z-popover); + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-2); + background: var(--foreground); + color: var(--card); + font-size: var(--font-size-caption); + opacity: 0; + pointer-events: none; + transform: translateX(-50%); + transition: opacity var(--duration-micro) ease-out; + white-space: nowrap; +} + +.icon-only-action:hover + .icon-only-action-tooltip, +.icon-only-action:focus-visible + .icon-only-action-tooltip { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto; + transition-duration: 0.01ms; + animation-duration: 0.01ms; + animation-iteration-count: 1; + } +} diff --git a/tests/e2e/accessibility-shell.spec.ts b/tests/e2e/accessibility-shell.spec.ts new file mode 100644 index 0000000..44a053c --- /dev/null +++ b/tests/e2e/accessibility-shell.spec.ts @@ -0,0 +1,77 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +const apiBaseUrl = "https://test-character-admin.sodalive.net"; +const sessionStorageKey = "ai-character-admin-auth-session"; + +async function openShell(page: import("@playwright/test").Page): Promise { + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, async (route) => { + await route.fulfill({ + contentType: "application/json", + json: { success: true, message: null, data: null, errorProperty: null }, + }); + }); + + await page.addInitScript( + ([key, value]) => { + window.sessionStorage.setItem(key, value); + }, + [sessionStorageKey, JSON.stringify({ token: "admin-token", role: "ADMIN" })], + ); + + await page.goto("/ai-characters"); + await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); +} + +test("keeps shell controls visible without horizontal overflow at 320px and 200 percent zoom", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + await openShell(page); + await page.evaluate(() => { + document.documentElement.style.zoom = "2"; + }); + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth); + await expect(page.getByRole("button", { name: "모바일 메뉴 열기" })).toBeInViewport(); + await expect(page.getByRole("button", { name: "로그아웃" })).toBeInViewport(); + expect(overflow).toBe(false); +}); + +test("keeps the open mobile menu visible and keyboard-contained at 320px and 200 percent zoom", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + await openShell(page); + await page.evaluate(() => { + document.documentElement.style.zoom = "2"; + }); + + await page.getByRole("button", { name: "모바일 메뉴 열기" }).focus(); + await page.keyboard.press("Enter"); + + const closeButton = page.getByRole("button", { name: "모바일 메뉴 닫기" }); + const navLink = page.getByRole("navigation", { name: "모바일 주 메뉴" }).getByRole("link", { name: "AI 캐릭터" }); + await expect(closeButton).toBeFocused(); + await expect(closeButton).toBeInViewport(); + await expect(navLink).toBeInViewport(); + + await page.keyboard.press("Shift+Tab"); + await expect(navLink).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(closeButton).toBeFocused(); + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth); + expect(overflow).toBe(false); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("navigation", { name: "모바일 주 메뉴" })).toBeHidden(); + await expect(page.getByRole("button", { name: "모바일 메뉴 열기" })).toBeFocused(); +}); + +test("has no critical or serious axe violations on the shell route", async ({ page }) => { + await openShell(page); + + const results = await new AxeBuilder({ page }).analyze(); + const blockingViolations = results.violations.filter( + (violation) => violation.impact === "critical" || violation.impact === "serious", + ); + + expect(blockingViolations).toEqual([]); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..2aeb3c1 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +const apiBaseUrl = "https://test-character-admin.sodalive.net"; + +test("completes login, navigation, and logout using only the keyboard", async ({ page }) => { + await page.route(`${apiBaseUrl}/admin/member/login`, async (route) => { + await route.fulfill({ + contentType: "application/json", + json: { success: true, message: null, data: { token: "admin-token", role: "ADMIN" }, errorProperty: null }, + }); + }); + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, async (route) => { + await route.fulfill({ + contentType: "application/json", + json: { success: true, message: null, data: null, errorProperty: null }, + }); + }); + await page.route(`${apiBaseUrl}/member/logout`, async (route) => { + await route.fulfill({ + contentType: "application/json", + json: { success: true, message: null, data: {}, errorProperty: null }, + }); + }); + + await page.goto("/login"); + await page.getByLabel("이메일").focus(); + await page.keyboard.type("admin@test.com"); + await page.getByLabel("비밀번호").focus(); + await page.keyboard.type("password"); + await page.getByRole("button", { name: "로그인" }).focus(); + await page.keyboard.press("Enter"); + + await expect(page).toHaveURL(/\/ai-characters$/); + await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); + + const logoutButton = page.getByRole("button", { name: "로그아웃" }); + await logoutButton.focus(); + await expect(logoutButton).toBeFocused(); + await page.keyboard.press("Enter"); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole("heading", { name: "관리자 로그인" })).toBeVisible(); +}); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 209602b..f3b5abd 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -4,5 +4,5 @@ test("opens the root shell", async ({ page }) => { await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("lang", "ko"); - await expect(page.getByRole("main")).toContainText("AI 캐릭터 관리자"); + await expect(page.getByRole("main")).toContainText("관리자 로그인"); }); diff --git a/tsconfig.app.json b/tsconfig.app.json index 8a0f0a7..f364875 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client", "vitest/globals"], + "types": ["vite/client"], "allowImportingTsExtensions": true, "module": "ESNext", "moduleResolution": "bundler", @@ -23,5 +23,11 @@ "jsx": "react-jsx", "noEmit": true }, - "include": ["src"] + "include": ["src"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/__tests__/**", + "src/shared/test/**" + ] } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..01490aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.test.json" } ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..791c06e --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client", "vitest/globals", "@testing-library/jest-dom"], + "allowImportingTsExtensions": true, + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "baseUrl": ".", + "ignoreDeprecations": "6.0", + "paths": { + "@/*": ["src/*"] + }, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "jsx": "react-jsx", + "noEmit": true + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/shared/test/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index 5fdedb8..36570b2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,9 @@ import react from "@vitejs/plugin-react"; -import { defineConfig } from "vitest/config"; +import tailwindcss from "@tailwindcss/vite"; +import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], server: { host: "127.0.0.1", port: 8888, @@ -17,5 +18,6 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["./src/shared/test/setup.ts"], globals: true, + exclude: [...configDefaults.exclude, "tests/e2e/**"], }, });