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 0000000..d7282b4 Binary files /dev/null and b/login-surface.png differ 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 0000000..bcf5c94 Binary files /dev/null and b/protected-shell-surface.png differ 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/**"], }, });