From 43499c7a73fdacd756416b4849f90356930b25b7 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 13 Jul 2026 22:00:46 +0900 Subject: [PATCH 001/281] =?UTF-8?q?MSG-106=20chore:=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=B6=81=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + package.json | 2 + packages/ui-web/.storybook/main.ts | 14 + packages/ui-web/.storybook/preview.css | 14 + packages/ui-web/.storybook/preview.ts | 15 + packages/ui-web/package.json | 16 +- packages/ui-web/src/button.stories.tsx | 49 + packages/ui-web/tailwind.config.ts | 9 + pnpm-lock.yaml | 1590 ++++++++++++++++++++++-- 9 files changed, 1631 insertions(+), 79 deletions(-) create mode 100644 packages/ui-web/.storybook/main.ts create mode 100644 packages/ui-web/.storybook/preview.css create mode 100644 packages/ui-web/.storybook/preview.ts create mode 100644 packages/ui-web/src/button.stories.tsx create mode 100644 packages/ui-web/tailwind.config.ts diff --git a/.gitignore b/.gitignore index dd6e803c..42d7b883 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ +storybook-static/ *.log .DS_Store diff --git a/package.json b/package.json index badf59dc..9d7ecd80 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "pnpm --filter web dev", "build": "pnpm --filter web build", "lint": "pnpm --filter web lint", + "storybook": "pnpm --filter @fillmap/ui-web storybook", + "build-storybook": "pnpm --filter @fillmap/ui-web build-storybook", "prepare": "husky" }, "license": "ISC", diff --git a/packages/ui-web/.storybook/main.ts b/packages/ui-web/.storybook/main.ts new file mode 100644 index 00000000..450671a3 --- /dev/null +++ b/packages/ui-web/.storybook/main.ts @@ -0,0 +1,14 @@ +import type { StorybookConfig } from "@storybook/react-vite"; +import { mergeConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(ts|tsx)"], + framework: "@storybook/react-vite", + viteFinal: (viteConfig) => + mergeConfig(viteConfig, { + plugins: [tailwindcss()], + }), +}; + +export default config; diff --git a/packages/ui-web/.storybook/preview.css b/packages/ui-web/.storybook/preview.css new file mode 100644 index 00000000..3837de6d --- /dev/null +++ b/packages/ui-web/.storybook/preview.css @@ -0,0 +1,14 @@ +/* + * apps/web/src/styles/globals.css 의 토큰 레이어 미러 — 스토리북 전용. + * globals.css 쪽 토큰 구성이 바뀌면 여기도 동기화한다. + */ +@import "tailwindcss"; +@import "@fontsource-variable/inter"; + +/* design-tokens 기반 preset (원시 색상·타이포·spacing·radius·shadow) */ +@config "../tailwind.config.ts"; +@source "../src"; + +@theme { + --font-sans: "Inter Variable", "Inter", system-ui, sans-serif; +} diff --git a/packages/ui-web/.storybook/preview.ts b/packages/ui-web/.storybook/preview.ts new file mode 100644 index 00000000..e0445892 --- /dev/null +++ b/packages/ui-web/.storybook/preview.ts @@ -0,0 +1,15 @@ +import type { Preview } from "@storybook/react-vite"; +import "./preview.css"; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/packages/ui-web/package.json b/packages/ui-web/package.json index 570636bb..9c188d50 100644 --- a/packages/ui-web/package.json +++ b/packages/ui-web/package.json @@ -8,6 +8,10 @@ "exports": { ".": "./src/index.ts" }, + "scripts": { + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, "dependencies": { "@fillmap/design-tokens": "workspace:*", "class-variance-authority": "^0.7.1", @@ -18,7 +22,17 @@ "react": ">=19" }, "devDependencies": { + "@fillmap/tailwind-preset": "workspace:*", + "@fontsource-variable/inter": "^5.2.8", + "@storybook/react-vite": "^10.5.0", + "@tailwindcss/vite": "^4.3.2", "@types/react": "^19.2.17", - "typescript": "~6.0.2" + "@types/react-dom": "^19.2.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "storybook": "^10.5.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.4" } } diff --git a/packages/ui-web/src/button.stories.tsx b/packages/ui-web/src/button.stories.tsx new file mode 100644 index 00000000..e80d3b24 --- /dev/null +++ b/packages/ui-web/src/button.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "./button"; + +/** Figma Button 컴포넌트 셋 (node 13021:535)의 Variant 속성과 1:1 */ +const variants = [ + "default", + "default-active", + "primary", + "secondary", + "danger", + "chip", + "chip-active", +] as const; + +const meta = { + title: "Components/Button", + component: Button, + args: { + text: "버튼", + variant: "primary", + disabled: false, + }, + argTypes: { + variant: { control: "select", options: variants }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** 컨트롤 패널에서 variant/disabled를 바꿔가며 확인 */ +export const Playground: Story = {}; + +/** 전체 variant × default/disabled 매트릭스 — Figma와 나란히 비교용 */ +export const AllVariants: Story = { + render: () => ( +
+ {variants.map((variant) => ( +
+ + {variant} + +
+ ))} +
+ ), +}; diff --git a/packages/ui-web/tailwind.config.ts b/packages/ui-web/tailwind.config.ts new file mode 100644 index 00000000..cf0fbcd6 --- /dev/null +++ b/packages/ui-web/tailwind.config.ts @@ -0,0 +1,9 @@ +import { preset } from "@fillmap/tailwind-preset"; +import type { Config } from "tailwindcss"; + +// .storybook/preview.css의 `@config`로 로드된다 (Tailwind v4). +// 앱 빌드에는 관여하지 않는다 — 앱은 apps/web/tailwind.config.ts를 사용. +export default { + presets: [preset], + content: ["./src/**/*.{ts,tsx}"], +} satisfies Config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7f40dec..f7fca252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ importers: version: link:../../packages/tailwind-preset '@tailwindcss/vite': specifier: ^4.3.2 - version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(jiti@2.7.0)) + version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) '@types/node': specifier: ^24.13.2 version: 24.13.3 @@ -98,7 +98,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.3 - version: 6.0.3(vite@8.1.4(@types/node@24.13.3)(jiti@2.7.0)) + version: 6.0.3(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) eslint: specifier: ^10.6.0 version: 10.6.0(jiti@2.7.0) @@ -125,7 +125,7 @@ importers: version: 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.1.1 - version: 8.1.4(@types/node@24.13.3)(jiti@2.7.0) + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) packages/design-tokens: {} @@ -150,22 +150,52 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - react: - specifier: '>=19' - version: 19.2.7 tailwind-merge: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@fillmap/tailwind-preset': + specifier: workspace:* + version: link:../tailwind-preset + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@storybook/react-vite': + specifier: ^10.5.0 + version: 10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(typescript@6.0.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) '@types/react': specifier: ^19.2.17 version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + storybook: + specifier: ^10.5.0 + version: 10.5.0(@types/react@19.2.17)(react@19.2.7) + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 typescript: specifier: ~6.0.2 version: 6.0.3 + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -309,12 +339,183 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -403,6 +604,15 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': + resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -447,9 +657,226 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-parser/binding-android-arm-eabi@0.127.0': + resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.127.0': + resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.127.0': + resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.127.0': + resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.127.0': + resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.127.0': + resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.127.0': + resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.127.0': + resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -1232,6 +1659,15 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -1242,6 +1678,81 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/builder-vite@10.5.0': + resolution: {integrity: sha512-KXlifNIThDgS84KqVAJXyilool8OLTWp6DGoO9h5bHM2IPLe7UcdKfOzMUBkQ807mWBk4aW1yGEekj7kC2dvmg==} + peerDependencies: + storybook: ^10.5.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@storybook/csf-plugin@10.5.0': + resolution: {integrity: sha512-R7VFw6FnDZTWct0ekOcFnTfDgdHnnAuQW+AbGG9A9IZPvyH9uLJivK7L86+r9MITRrO2+v81HaFDsT/OFk6ZkQ==} + peerDependencies: + esbuild: '*' + rollup: '*' + storybook: ^10.5.0 + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + + '@storybook/global@5.0.0': + resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} + + '@storybook/icons@2.1.0': + resolution: {integrity: sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@storybook/react-dom-shim@10.5.0': + resolution: {integrity: sha512-GwGA6zDj4Cfw6vGsdfPKfF39L0W6solNbbnjdjGa57m2ooASkidLhrXo2wgC9wh3o/jcfonmYPDRGY6sEhGDtg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@storybook/react-vite@10.5.0': + resolution: {integrity: sha512-d9n/I3pViscXpJYT9JHxcpxVSam14HsHOr2wBqTQTiRtaiH4xSsT00HTEGm6CEZTyq/npTJyV0Qday9XhrtiDQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.0 + typescript: '>= 4.9.x' + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@storybook/react@10.5.0': + resolution: {integrity: sha512-2IhddiREy7NqAqnFsEOfW6HBG7azIcLxhRQYploSsaemOncR29xhzvAZsG+xlWgrtvxv4h3fYQTTR4TNn8CgPQ==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.0 + typescript: '>= 4.9.x' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + typescript: + optional: true + '@tailwindcss/node@4.3.2': resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} @@ -1340,12 +1851,50 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/doctrine@0.0.9': + resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1366,6 +1915,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/resolve@1.20.6': + resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -1441,6 +1993,21 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@webcontainer/env@1.1.1': + resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1493,6 +2060,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1500,6 +2071,17 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -1563,10 +2145,18 @@ packages: caniuse-lite@1.0.30001803: resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1645,6 +2235,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1677,6 +2270,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1708,6 +2305,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1719,6 +2320,16 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -1740,6 +2351,10 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1775,6 +2390,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1840,6 +2460,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2009,6 +2632,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@17.7.0: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} @@ -2083,6 +2710,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2097,6 +2728,10 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -2227,6 +2862,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -2333,6 +2971,13 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2341,6 +2986,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2395,6 +3044,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2402,6 +3055,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2456,6 +3113,10 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -2472,6 +3133,13 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + oxc-parser@0.127.0: + resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -2527,9 +3195,20 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2565,6 +3244,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -2613,6 +3296,15 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-docgen-typescript@2.4.0: + resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} + peerDependencies: + typescript: '>= 4.3.x' + + react-docgen@8.0.3: + resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} + engines: {node: ^20.9.0 || >=22} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -2624,6 +3316,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-kakao-maps-sdk@1.2.1: resolution: {integrity: sha512-qvdt+82D/MxTxmgF9tXaqa6eNqMUiFOeEdn+PyU0u9EHoxeD9WMJeHkum/GWrbP62MR0qWPtZ/G4iM9f2/1TPQ==} peerDependencies: @@ -2686,6 +3381,10 @@ packages: resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==} engines: {node: '>= 4'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2694,6 +3393,11 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -2802,6 +3506,21 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + storybook@10.5.0: + resolution: {integrity: sha512-dRhM/kSSvHQR8DmZO41v5sJuz9U6zDjjR2gRBTgZN2RBSXbmF0Brvgszrvvxyx2VfxuYKzhB+xumKwWkwlBtig==} + hasBin: true + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + prettier: ^2 || ^3 + vite-plus: ^0.1.15 || ^0.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + prettier: + optional: true + vite-plus: + optional: true + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -2830,6 +3549,18 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + systeminformation@5.31.15: resolution: {integrity: sha512-7mqCtD28TK5dVdLAQONVa/Do/NBgMH2dxqf49nh6DIKoEWuDg6tkgGBP+dN22VEJVPZa/QqiHomhWNRn4WUNTQ==} engines: {node: '>=8.0.0'} @@ -2853,6 +3584,14 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2867,6 +3606,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} @@ -2919,6 +3662,10 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3007,6 +3754,9 @@ packages: yaml: optional: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3024,6 +3774,22 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -3080,6 +3846,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3295,16 +4063,121 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': dependencies: eslint: 10.6.0(jiti@2.7.0) @@ -3358,92 +4231,241 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.14(hono@4.12.28)': - dependencies: - hono: 4.12.28 + '@hono/node-server@1.19.14(hono@4.12.28)': + dependencies: + hono: 4.12.28 + + '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.7))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.81.0(react@19.2.7) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + glob: 13.0.6 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + optionalDependencies: + typescript: 6.0.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.28) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.28 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-parser/binding-android-arm-eabi@0.127.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.127.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.127.0': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + optional: true + + '@oxc-project/types@0.127.0': {} + + '@oxc-project/types@0.139.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true - '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.7))': - dependencies: - '@standard-schema/utils': 0.3.0 - react-hook-form: 7.81.0(react@19.2.7) + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true - '@humanfs/types@0.15.0': {} + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true - '@humanwhocodes/module-importer@1.0.1': {} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true - '@humanwhocodes/retry@0.4.3': {} + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true - '@jridgewell/resolve-uri@3.1.2': {} + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true - '@jridgewell/sourcemap-codec@1.5.5': {} + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true - '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.14(hono@4.12.28) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.28 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - supports-color + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': optional: true - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true - '@nodelib/fs.stat@2.0.5': {} + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true - '@nodelib/fs.walk@1.2.8': + '@oxc-resolver/binding-wasm32-wasi@11.24.2': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true - '@oxc-project/types@0.139.0': {} + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true '@radix-ui/number@1.1.2': {} @@ -4242,12 +5264,93 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} '@standard-schema/utils@0.3.0': {} + '@storybook/builder-vite@10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@storybook/csf-plugin': 10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + storybook: 10.5.0(@types/react@19.2.17)(react@19.2.7) + ts-dedent: 2.3.0 + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + transitivePeerDependencies: + - esbuild + - rollup + - webpack + + '@storybook/csf-plugin@10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + storybook: 10.5.0(@types/react@19.2.17)(react@19.2.7) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.1 + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + + '@storybook/global@5.0.0': {} + + '@storybook/icons@2.1.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@storybook/react-dom-shim@10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))': + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + storybook: 10.5.0(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@storybook/react-vite@10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(typescript@6.0.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@rollup/pluginutils': 5.4.0 + '@storybook/builder-vite': 10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@storybook/react': 10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(typescript@6.0.3) + empathic: 2.0.1 + magic-string: 0.30.21 + react: 19.2.7 + react-docgen: 8.0.3 + react-dom: 19.2.7(react@19.2.7) + resolve: 1.22.12 + storybook: 10.5.0(@types/react@19.2.17)(react@19.2.7) + tsconfig-paths: 4.2.0 + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - esbuild + - rollup + - supports-color + - webpack + + '@storybook/react@10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(typescript@6.0.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7)) + react: 19.2.7 + react-docgen: 8.0.3 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + react-dom: 19.2.7(react@19.2.7) + storybook: 10.5.0(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4309,12 +5412,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@24.13.3)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: 8.1.4(@types/node@24.13.3)(jiti@2.7.0) + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) '@tanstack/query-core@5.101.2': {} @@ -4323,6 +5426,30 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -4334,6 +5461,38 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/doctrine@0.0.9': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -4352,6 +5511,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/resolve@1.20.6': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': @@ -4445,10 +5606,34 @@ snapshots: '@typescript-eslint/types': 8.63.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@24.13.3)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.4(@types/node@24.13.3)(jiti@2.7.0) + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@webcontainer/env@1.1.1': {} accepts@2.0.0: dependencies: @@ -4495,12 +5680,22 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@5.2.0: {} + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -4573,8 +5768,18 @@ snapshots: caniuse-lite@1.0.30001803: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@5.6.2: {} + check-error@2.1.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -4644,6 +5849,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -4660,6 +5867,8 @@ snapshots: dedent@1.7.2: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -4679,12 +5888,22 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} diff@8.0.4: {} + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -4703,6 +5922,8 @@ snapshots: emoji-regex@10.6.0: {} + empathic@2.0.1: {} + encodeurl@2.0.0: {} enhanced-resolve@5.21.6: @@ -4736,6 +5957,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -4823,6 +6073,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + esutils@2.0.3: {} etag@1.8.1: {} @@ -5029,6 +6281,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@17.7.0: {} gopd@1.2.0: {} @@ -5089,6 +6347,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} ip-address@10.2.0: {} @@ -5097,6 +6357,10 @@ snapshots: is-arrayish@0.2.1: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-docker@2.2.1: {} is-docker@3.0.0: {} @@ -5175,6 +6439,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -5261,6 +6527,10 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + loupe@3.2.1: {} + + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5269,6 +6539,8 @@ snapshots: dependencies: react: 19.2.7 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5306,12 +6578,16 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 minimist@1.2.8: {} + minipass@7.1.3: {} + ms@2.1.3: {} nanoid@3.3.15: {} @@ -5353,6 +6629,13 @@ snapshots: dependencies: mimic-function: 5.0.1 + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -5389,6 +6672,53 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + oxc-parser@0.127.0: + dependencies: + '@oxc-project/types': 0.127.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.127.0 + '@oxc-parser/binding-android-arm64': 0.127.0 + '@oxc-parser/binding-darwin-arm64': 0.127.0 + '@oxc-parser/binding-darwin-x64': 0.127.0 + '@oxc-parser/binding-freebsd-x64': 0.127.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.127.0 + '@oxc-parser/binding-linux-arm64-musl': 0.127.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.127.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-musl': 0.127.0 + '@oxc-parser/binding-openharmony-arm64': 0.127.0 + '@oxc-parser/binding-wasm32-wasi': 0.127.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.127.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 + '@oxc-parser/binding-win32-x64-msvc': 0.127.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -5432,8 +6762,17 @@ snapshots: path-key@4.0.0: {} + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5461,6 +6800,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -5558,6 +6903,25 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + react-docgen-typescript@2.4.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + react-docgen@8.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + '@types/doctrine': 0.0.9 + '@types/resolve': 1.20.6 + doctrine: 3.0.0 + resolve: 1.22.12 + strip-indent: 4.1.1 + transitivePeerDependencies: + - supports-color + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -5567,6 +6931,8 @@ snapshots: dependencies: react: 19.2.7 + react-is@17.0.2: {} + react-kakao-maps-sdk@1.2.1(kakao.maps.d.ts@0.1.40)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@babel/runtime': 7.29.7 @@ -5626,10 +6992,22 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -5799,6 +7177,32 @@ snapshots: stdin-discarder@0.2.2: {} + storybook@10.5.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.1.0(react@19.2.7) + '@testing-library/dom': 10.4.1 + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/expect': 3.2.4 + '@vitest/spy': 3.2.4 + '@webcontainer/env': 1.1.1 + esbuild: 0.28.1 + jsonc-parser: 3.3.1 + open: 10.2.0 + oxc-parser: 0.127.0 + oxc-resolver: 11.24.2 + recast: 0.23.12 + semver: 7.8.5 + use-sync-external-store: 1.6.0(react@19.2.7) + ws: 8.21.0 + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -5825,6 +7229,14 @@ snapshots: strip-final-newline@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-indent@4.1.1: {} + + supports-preserve-symlinks-flag@1.0.0: {} + systeminformation@5.31.15: {} tailwind-merge@3.6.0: {} @@ -5840,6 +7252,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5850,6 +7266,8 @@ snapshots: dependencies: typescript: 6.0.3 + ts-dedent@2.3.0: {} + ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 @@ -5898,6 +7316,13 @@ snapshots: unpipe@1.0.0: {} + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + update-browserslist-db@1.2.3(browserslist@4.28.5): dependencies: browserslist: 4.28.5 @@ -5933,7 +7358,7 @@ snapshots: vary@1.1.2: {} - vite@8.1.4(@types/node@24.13.3)(jiti@2.7.0): + vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -5942,9 +7367,12 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 + esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 + webpack-virtual-modules@0.6.2: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -5957,6 +7385,12 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 From c7cdebe1a72eafc360845ff6467b54e5009f5589 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 14 Jul 2026 17:07:45 +0900 Subject: [PATCH 002/281] =?UTF-8?q?MSG-107=20feat:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/App.tsx | 13 +-- packages/design-tokens/src/variants.ts | 82 ++++++++++++++--- packages/ui-web/package.json | 3 + packages/ui-web/src/app-header.stories.tsx | 19 ++++ packages/ui-web/src/app-header.tsx | 46 ++++++++++ packages/ui-web/src/avatar.stories.tsx | 27 ++++++ packages/ui-web/src/avatar.tsx | 49 +++++++++++ packages/ui-web/src/bottom-nav.stories.tsx | 30 +++++++ packages/ui-web/src/bottom-nav.tsx | 85 ++++++++++++++++++ packages/ui-web/src/bottom-sheet.stories.tsx | 26 ++++++ packages/ui-web/src/bottom-sheet.tsx | 55 ++++++++++++ packages/ui-web/src/button.stories.tsx | 25 +++--- packages/ui-web/src/button.tsx | 36 ++++---- packages/ui-web/src/cell-badge.stories.tsx | 13 +++ packages/ui-web/src/cell-badge.tsx | 24 +++++ packages/ui-web/src/chip.stories.tsx | 23 +++++ packages/ui-web/src/chip.tsx | 41 +++++++++ packages/ui-web/src/dots.stories.tsx | 13 +++ packages/ui-web/src/dots.tsx | 29 +++++++ packages/ui-web/src/fab.stories.tsx | 13 +++ packages/ui-web/src/fab.tsx | 27 ++++++ packages/ui-web/src/grid-cell.stories.tsx | 27 ++++++ packages/ui-web/src/grid-cell.tsx | 41 +++++++++ packages/ui-web/src/index.ts | 19 ++++ packages/ui-web/src/input.stories.tsx | 25 ++++++ packages/ui-web/src/input.tsx | 27 ++++++ .../ui-web/src/map-icon-button.stories.tsx | 26 ++++++ packages/ui-web/src/map-icon-button.tsx | 51 +++++++++++ packages/ui-web/src/modal-card.stories.tsx | 29 +++++++ packages/ui-web/src/modal-card.tsx | 87 +++++++++++++++++++ packages/ui-web/src/search-bar.stories.tsx | 20 +++++ packages/ui-web/src/search-bar.tsx | 31 +++++++ packages/ui-web/src/selector.stories.tsx | 28 ++++++ packages/ui-web/src/selector.tsx | 62 +++++++++++++ packages/ui-web/src/side-rail.stories.tsx | 39 +++++++++ packages/ui-web/src/side-rail.tsx | 71 +++++++++++++++ packages/ui-web/src/switch.stories.tsx | 25 ++++++ packages/ui-web/src/switch.tsx | 40 +++++++++ packages/ui-web/src/toast.stories.tsx | 43 +++++++++ packages/ui-web/src/toast.tsx | 65 ++++++++++++++ packages/ui-web/src/video-row.stories.tsx | 32 +++++++ packages/ui-web/src/video-row.tsx | 53 +++++++++++ packages/ui-web/src/zoom-control.stories.tsx | 18 ++++ packages/ui-web/src/zoom-control.tsx | 41 +++++++++ packages/ui-web/tsconfig.json | 18 ++++ pnpm-lock.yaml | 6 ++ 46 files changed, 1550 insertions(+), 53 deletions(-) create mode 100644 packages/ui-web/src/app-header.stories.tsx create mode 100644 packages/ui-web/src/app-header.tsx create mode 100644 packages/ui-web/src/avatar.stories.tsx create mode 100644 packages/ui-web/src/avatar.tsx create mode 100644 packages/ui-web/src/bottom-nav.stories.tsx create mode 100644 packages/ui-web/src/bottom-nav.tsx create mode 100644 packages/ui-web/src/bottom-sheet.stories.tsx create mode 100644 packages/ui-web/src/bottom-sheet.tsx create mode 100644 packages/ui-web/src/cell-badge.stories.tsx create mode 100644 packages/ui-web/src/cell-badge.tsx create mode 100644 packages/ui-web/src/chip.stories.tsx create mode 100644 packages/ui-web/src/chip.tsx create mode 100644 packages/ui-web/src/dots.stories.tsx create mode 100644 packages/ui-web/src/dots.tsx create mode 100644 packages/ui-web/src/fab.stories.tsx create mode 100644 packages/ui-web/src/fab.tsx create mode 100644 packages/ui-web/src/grid-cell.stories.tsx create mode 100644 packages/ui-web/src/grid-cell.tsx create mode 100644 packages/ui-web/src/input.stories.tsx create mode 100644 packages/ui-web/src/input.tsx create mode 100644 packages/ui-web/src/map-icon-button.stories.tsx create mode 100644 packages/ui-web/src/map-icon-button.tsx create mode 100644 packages/ui-web/src/modal-card.stories.tsx create mode 100644 packages/ui-web/src/modal-card.tsx create mode 100644 packages/ui-web/src/search-bar.stories.tsx create mode 100644 packages/ui-web/src/search-bar.tsx create mode 100644 packages/ui-web/src/selector.stories.tsx create mode 100644 packages/ui-web/src/selector.tsx create mode 100644 packages/ui-web/src/side-rail.stories.tsx create mode 100644 packages/ui-web/src/side-rail.tsx create mode 100644 packages/ui-web/src/switch.stories.tsx create mode 100644 packages/ui-web/src/switch.tsx create mode 100644 packages/ui-web/src/toast.stories.tsx create mode 100644 packages/ui-web/src/toast.tsx create mode 100644 packages/ui-web/src/video-row.stories.tsx create mode 100644 packages/ui-web/src/video-row.tsx create mode 100644 packages/ui-web/src/zoom-control.stories.tsx create mode 100644 packages/ui-web/src/zoom-control.tsx create mode 100644 packages/ui-web/tsconfig.json diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 01b17407..b17f6190 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,15 +6,7 @@ import { type ButtonVariant, } from "@fillmap/design-tokens"; -const buttonVariants: ButtonVariant[] = [ - "default", - "default-active", - "primary", - "secondary", - "danger", - "chip", - "chip-active", -]; +const buttonVariants: ButtonVariant[] = ["primary", "secondary", "danger"]; /** 디자인 시스템 동작 확인용 데모 페이지 — 실제 화면 구현 시 pages/로 대체 */ function App() { @@ -33,6 +25,9 @@ function App() { {buttonVariants.map((v) => ( + ) : ( + + )} +

+ {title} +

+ + {right} + + +); diff --git a/packages/ui-web/src/avatar.stories.tsx b/packages/ui-web/src/avatar.stories.tsx new file mode 100644 index 00000000..e3346432 --- /dev/null +++ b/packages/ui-web/src/avatar.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Avatar } from "./avatar"; + +const meta = { + title: "Components/Avatar", + component: Avatar, + args: { size: "lg", fallback: "김" }, + argTypes: { + size: { control: "select", options: ["lg", "md", "sm"] }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +/** 48/36/28 — Figma Size와 1:1 */ +export const AllSizes: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/ui-web/src/avatar.tsx b/packages/ui-web/src/avatar.tsx new file mode 100644 index 00000000..9b763e8f --- /dev/null +++ b/packages/ui-web/src/avatar.tsx @@ -0,0 +1,49 @@ +import { Avatar as AvatarPrimitive } from "radix-ui"; +import { cva } from "class-variance-authority"; +import type { AvatarBaseProps } from "@fillmap/design-tokens"; +import { cn } from "./lib/utils"; + +/** SOURCE: Figma "FeelMap Avatar" (node 13430:714) — Size lg/md/sm = 48/36/28px */ +const avatarVariants = cva( + "relative flex shrink-0 overflow-hidden rounded-full bg-surface", + { + variants: { + size: { + lg: "size-[48px]", + md: "size-[36px]", + sm: "size-[28px]", + }, + }, + defaultVariants: { size: "lg" }, + }, +); + +interface AvatarProps extends AvatarBaseProps { + src?: string; + alt?: string; + /** 이미지 로드 실패/미지정 시 보여줄 짧은 텍스트 (예: 이니셜) */ + fallback?: string; + className?: string; +} + +/** + * Radix Avatar 기반 (shadcn 패턴). + * + * @example + * + * + */ +export const Avatar = ({ size, src, alt, fallback, className }: AvatarProps) => ( + + {src && ( + + )} + + {fallback} + + +); diff --git a/packages/ui-web/src/bottom-nav.stories.tsx b/packages/ui-web/src/bottom-nav.stories.tsx new file mode 100644 index 00000000..9bdcfe00 --- /dev/null +++ b/packages/ui-web/src/bottom-nav.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Compass, Home, LayoutGrid, User } from "lucide-react"; +import { BottomNav } from "./bottom-nav"; + +const items = [ + { key: "home", label: "홈", icon: }, + { key: "explore", label: "탐색", icon: }, + { key: "dex", label: "도감", icon: }, + { key: "profile", label: "프로필", icon: }, +]; + +const meta = { + title: "Components/BottomNav", + component: BottomNav, + args: { items, activeKey: "home" }, + argTypes: { + activeKey: { control: "select", options: items.map((i) => i.key) }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/ui-web/src/bottom-nav.tsx b/packages/ui-web/src/bottom-nav.tsx new file mode 100644 index 00000000..a56e4344 --- /dev/null +++ b/packages/ui-web/src/bottom-nav.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from "react"; +import { Camera } from "lucide-react"; +import { cn } from "./lib/utils"; + +export interface BottomNavItem { + key: string; + label: string; + icon: ReactNode; +} + +interface BottomNavProps { + /** 탭 목록 — 앞 절반은 카메라 버튼 왼쪽, 뒤 절반은 오른쪽에 배치된다 */ + items: BottomNavItem[]; + activeKey?: string; + onSelect?: (key: string) => void; + onCamera?: () => void; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap BottomNav" (node 13406:742) — 앱 하단 내비 (볼록 카메라, h 84). + * 탭 아이콘/라벨은 도메인이므로 items로 주입한다. + * + * @example + * }, ...]} + * activeKey="home" + * onSelect={setTab} + * onCamera={openCamera} + * /> + */ +export const BottomNav = ({ + items, + activeKey, + onSelect, + onCamera, + className, +}: BottomNavProps) => { + const mid = Math.ceil(items.length / 2); + + const renderTab = (item: BottomNavItem) => { + const isActive = item.key === activeKey; + return ( + + ); + }; + + return ( + + ); +}; diff --git a/packages/ui-web/src/bottom-sheet.stories.tsx b/packages/ui-web/src/bottom-sheet.stories.tsx new file mode 100644 index 00000000..53b9b2b7 --- /dev/null +++ b/packages/ui-web/src/bottom-sheet.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BottomSheet } from "./bottom-sheet"; +import { VideoRow } from "./video-row"; + +const meta = { + title: "Components/BottomSheet", + component: BottomSheet, + args: { + title: "이 지역 격자 24개 · 영상 138개", + actionLabel: "전체 보기", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ + + + +
+ ), +}; diff --git a/packages/ui-web/src/bottom-sheet.tsx b/packages/ui-web/src/bottom-sheet.tsx new file mode 100644 index 00000000..4f41623e --- /dev/null +++ b/packages/ui-web/src/bottom-sheet.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import { cn } from "./lib/utils"; + +interface BottomSheetProps { + title?: string; + /** 타이틀 우측 액션 텍스트 (예: "전체 보기") */ + actionLabel?: string; + onAction?: () => void; + /** 콘텐츠 슬롯 */ + children?: ReactNode; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap BottomSheet" (node 13406:687) — 바텀시트 쉘 (앱) / 도킹 패널 헤더 (웹). + * 프레젠테이셔널 쉘 — 드래그/스냅 동작은 사용하는 쪽(vaul 등)에서 감싼다. + * + * @example + * + * + * + */ +export const BottomSheet = ({ + title, + actionLabel, + onAction, + children, + className, +}: BottomSheetProps) => ( +
+
+ +
+ {(title || actionLabel) && ( +
+

{title}

+ {actionLabel && ( + + )} +
+ )} + {children} +
+); diff --git a/packages/ui-web/src/button.stories.tsx b/packages/ui-web/src/button.stories.tsx index e80d3b24..be4df201 100644 --- a/packages/ui-web/src/button.stories.tsx +++ b/packages/ui-web/src/button.stories.tsx @@ -1,16 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Button } from "./button"; -/** Figma Button 컴포넌트 셋 (node 13021:535)의 Variant 속성과 1:1 */ -const variants = [ - "default", - "default-active", - "primary", - "secondary", - "danger", - "chip", - "chip-active", -] as const; +/** Figma "FeelMap Button" (node 13427:723)의 Variant 속성과 1:1 */ +const variants = ["primary", "secondary", "danger"] as const; +const sizes = ["lg", "sm"] as const; const meta = { title: "Components/Button", @@ -18,29 +11,33 @@ const meta = { args: { text: "버튼", variant: "primary", + size: "lg", disabled: false, }, argTypes: { variant: { control: "select", options: variants }, + size: { control: "select", options: sizes }, }, } satisfies Meta; export default meta; type Story = StoryObj; -/** 컨트롤 패널에서 variant/disabled를 바꿔가며 확인 */ +/** 컨트롤 패널에서 variant/size/disabled를 바꿔가며 확인 */ export const Playground: Story = {}; -/** 전체 variant × default/disabled 매트릭스 — Figma와 나란히 비교용 */ +/** Type × Size × State 매트릭스 — Figma와 나란히 비교용 */ export const AllVariants: Story = { render: () => (
{variants.map((variant) => (
- + {variant} -
))} diff --git a/packages/ui-web/src/button.tsx b/packages/ui-web/src/button.tsx index 1e9215a7..a10f070e 100644 --- a/packages/ui-web/src/button.tsx +++ b/packages/ui-web/src/button.tsx @@ -4,30 +4,25 @@ import type { ButtonBaseProps } from "@fillmap/design-tokens"; import { cn } from "./lib/utils"; /** - * SOURCE: Figma Button 컴포넌트 셋 (node 13021:535) - * 와이어프레임의 placeholder 색은 FeelMap 시맨틱 토큰으로 매핑했다 (사용자 확인 완료). - * min-w, py-[6px] 등은 컴포넌트 고유 치수 — variant 정의 안에서만 허용 (규칙 1). + * SOURCE: Figma "FeelMap Button" (node 13427:723) + * State=pressed(14% 검정 오버레이)는 active:brightness-[0.86]으로 재현 — 채널×0.86과 동일한 결과. + * h/min-w 값은 컴포넌트 고유 치수 — variant 정의 안에서만 허용 (규칙 1). */ const buttonVariants = cva( - "inline-flex items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center font-semibold transition-[filter,background-color] active:brightness-[0.86] disabled:pointer-events-none disabled:bg-background disabled:text-foreground-muted", { variants: { variant: { - default: - "text-fm-base text-muted-foreground underline underline-offset-2 hover:text-foreground", - "default-active": "text-fm-base font-bold text-foreground", - primary: - "min-w-[60px] rounded-sm bg-primary px-md py-xs text-fm-base font-medium text-primary-foreground hover:bg-primary/90", - secondary: - "min-w-[60px] rounded-sm border border-foreground bg-background px-md py-xs text-fm-base font-medium text-foreground hover:bg-surface", - danger: - "min-w-[60px] rounded-sm bg-error/15 px-md py-xs text-fm-base font-medium text-error hover:bg-error/25", - chip: "min-w-[40px] rounded-full border border-border bg-surface px-sm py-[6px] text-fm-base font-medium text-foreground hover:bg-gray-200", - "chip-active": - "min-w-[40px] rounded-full bg-foreground px-sm py-[6px] text-fm-base font-medium text-background", + primary: "bg-primary text-primary-foreground", + secondary: "bg-background text-foreground", + danger: "bg-error text-primary-foreground", + }, + size: { + lg: "h-[48px] min-w-[140px] rounded-md px-lg text-fm-title leading-none", + sm: "h-[36px] min-w-[104px] rounded-sm px-md text-fm-body-strong", }, }, - defaultVariants: { variant: "default" }, + defaultVariants: { variant: "primary", size: "lg" }, }, ); @@ -38,22 +33,23 @@ interface ButtonProps } /** - * 공용 Button. variant는 Figma Button 컴포넌트의 Variant 속성과 1:1. + * 공용 Button. variant/size는 Figma Button 컴포넌트의 Variant 속성과 1:1. * * @example * +); diff --git a/packages/ui-web/src/dots.stories.tsx b/packages/ui-web/src/dots.stories.tsx new file mode 100644 index 00000000..739fbe6d --- /dev/null +++ b/packages/ui-web/src/dots.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Dots } from "./dots"; + +const meta = { + title: "Components/Dots", + component: Dots, + args: { count: 3, activeIndex: 0 }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/packages/ui-web/src/dots.tsx b/packages/ui-web/src/dots.tsx new file mode 100644 index 00000000..b0a9cb38 --- /dev/null +++ b/packages/ui-web/src/dots.tsx @@ -0,0 +1,29 @@ +import { cn } from "./lib/utils"; + +interface DotsProps { + /** 전체 페이지 수. 기본 3 */ + count?: number; + /** 활성 페이지 인덱스 (0부터). 기본 0 */ + activeIndex?: number; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap Dots" (node 13404:703) — 페이지 인디케이터. + * + * @example + * + */ +export const Dots = ({ count = 3, activeIndex = 0, className }: DotsProps) => ( +
+ {Array.from({ length: count }, (_, i) => ( + + ))} +
+); diff --git a/packages/ui-web/src/fab.stories.tsx b/packages/ui-web/src/fab.stories.tsx new file mode 100644 index 00000000..c75816b8 --- /dev/null +++ b/packages/ui-web/src/fab.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Fab } from "./fab"; + +const meta = { + title: "Components/Fab", + component: Fab, + args: { "aria-label": "기록하기" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/packages/ui-web/src/fab.tsx b/packages/ui-web/src/fab.tsx new file mode 100644 index 00000000..d28242fb --- /dev/null +++ b/packages/ui-web/src/fab.tsx @@ -0,0 +1,27 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { Plus } from "lucide-react"; +import { cn } from "./lib/utils"; + +interface FabProps extends ButtonHTMLAttributes { + /** 아이콘 슬롯. 기본 + */ + icon?: ReactNode; +} + +/** + * SOURCE: Figma "FeelMap FAB" (node 13431:697) — 기록/업로드 플로팅 버튼 (56px). + * + * @example + * + */ +export const Fab = ({ icon, className, type = "button", ...props }: FabProps) => ( + +); diff --git a/packages/ui-web/src/grid-cell.stories.tsx b/packages/ui-web/src/grid-cell.stories.tsx new file mode 100644 index 00000000..87b2ad3c --- /dev/null +++ b/packages/ui-web/src/grid-cell.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { GridCell } from "./grid-cell"; + +const meta = { + title: "Components/GridCell", + component: GridCell, + args: { state: "default", className: "size-[130px]" }, + argTypes: { + state: { control: "select", options: ["default", "collected", "selected"] }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +/** 기본/수집/선택 — Figma State와 1:1 */ +export const AllStates: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/ui-web/src/grid-cell.tsx b/packages/ui-web/src/grid-cell.tsx new file mode 100644 index 00000000..e744e5bf --- /dev/null +++ b/packages/ui-web/src/grid-cell.tsx @@ -0,0 +1,41 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cva } from "class-variance-authority"; +import type { GridCellBaseProps } from "@fillmap/design-tokens"; +import { cn } from "./lib/utils"; + +/** + * SOURCE: Figma "FeelMap GridCell" (node 13405:690) — 지도 격자 (기본/수집/선택). + * 크기는 지도 오버레이가 결정하므로 className으로 지정한다. + * 채움 투명도는 Figma 렌더링 기준으로 근사 (기본: 옅은 테두리 / 수집: 진한 채움 / 선택: 옅은 채움 + 굵은 테두리). + */ +const gridCellVariants = cva("block transition-colors", { + variants: { + state: { + default: "border-[1.5px] border-primary/40 bg-primary/5", + collected: "border border-primary/60 bg-primary/40", + selected: "border-2 border-primary bg-primary/15", + }, + }, + defaultVariants: { state: "default" }, +}); + +interface GridCellProps + extends GridCellBaseProps, + ButtonHTMLAttributes {} + +/** + * @example + * + */ +export const GridCell = ({ + state, + className, + type = "button", + ...props +}: GridCellProps) => ( + +); diff --git a/packages/ui-web/src/modal-card.stories.tsx b/packages/ui-web/src/modal-card.stories.tsx new file mode 100644 index 00000000..d7333082 --- /dev/null +++ b/packages/ui-web/src/modal-card.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ModalCard } from "./modal-card"; + +const meta = { + title: "Components/ModalCard", + component: ModalCard, + args: { + title: "모달 타이틀", + description: "설명 텍스트가 들어갑니다. 상황에 맞는 안내 문구를 작성하세요.", + cancelText: "취소", + confirmText: "확인", + onClose: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ +
+ 콘텐츠 영역 +
+
+
+ ), +}; diff --git a/packages/ui-web/src/modal-card.tsx b/packages/ui-web/src/modal-card.tsx new file mode 100644 index 00000000..e90d70aa --- /dev/null +++ b/packages/ui-web/src/modal-card.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from "react"; +import { X } from "lucide-react"; +import { cn } from "./lib/utils"; + +interface ModalCardProps { + title: string; + description?: string; + /** 콘텐츠 슬롯 */ + children?: ReactNode; + cancelText?: string; + confirmText?: string; + onCancel?: () => void; + onConfirm?: () => void; + /** 지정하면 우측 상단 닫기 버튼 표시 */ + onClose?: () => void; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap ModalCard" (node 13406:696) — 모달/다이얼로그 쉘. + * 프레젠테이셔널 카드 — 오버레이/포털/포커스 트랩은 사용하는 쪽(Radix Dialog 등)에서 감싼다. + * + * @example + * + */ +export const ModalCard = ({ + title, + description, + children, + cancelText, + confirmText, + onCancel, + onConfirm, + onClose, + className, +}: ModalCardProps) => ( +
+
+

+ {title} +

+ {onClose && ( + + )} +
+ {description && ( +

{description}

+ )} + {children} + {(cancelText || confirmText) && ( +
+ {cancelText && ( + + )} + {confirmText && ( + + )} +
+ )} +
+); diff --git a/packages/ui-web/src/search-bar.stories.tsx b/packages/ui-web/src/search-bar.stories.tsx new file mode 100644 index 00000000..348547d5 --- /dev/null +++ b/packages/ui-web/src/search-bar.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SearchBar } from "./search-bar"; + +const meta = { + title: "Components/SearchBar", + component: SearchBar, + args: { placeholder: "장소, 격자, 영상 검색" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** focused 상태는 입력창 클릭으로 확인 */ +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/ui-web/src/search-bar.tsx b/packages/ui-web/src/search-bar.tsx new file mode 100644 index 00000000..c21c4d1a --- /dev/null +++ b/packages/ui-web/src/search-bar.tsx @@ -0,0 +1,31 @@ +import type { InputHTMLAttributes } from "react"; +import { Search } from "lucide-react"; +import { cn } from "./lib/utils"; + +interface SearchBarProps + extends Omit, "size"> { + className?: string; +} + +/** + * SOURCE: Figma "FeelMap SearchBar" (node 13431:710) — 지도 검색바 (h 48). + * State=focused는 focus-within: 인터랙션으로 처리. + * + * @example + * + */ +export const SearchBar = ({ className, ...props }: SearchBarProps) => ( +
+ + +
+); diff --git a/packages/ui-web/src/selector.stories.tsx b/packages/ui-web/src/selector.stories.tsx new file mode 100644 index 00000000..a96ee9e2 --- /dev/null +++ b/packages/ui-web/src/selector.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Selector } from "./selector"; + +const meta = { + title: "Components/Selector", + component: Selector, + args: { type: "checkbox", disabled: false }, + argTypes: { + type: { control: "select", options: ["checkbox", "radio"] }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +/** checkbox/radio × off/on — Figma variant와 1:1 */ +export const AllVariants: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/packages/ui-web/src/selector.tsx b/packages/ui-web/src/selector.tsx new file mode 100644 index 00000000..5f6cdf77 --- /dev/null +++ b/packages/ui-web/src/selector.tsx @@ -0,0 +1,62 @@ +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import { Check } from "lucide-react"; +import { cva } from "class-variance-authority"; +import type { SelectorBaseProps } from "@fillmap/design-tokens"; +import { cn } from "./lib/utils"; + +/** + * SOURCE: Figma "FeelMap Selector" (node 13430:703) — 체크박스/라디오 (20×20). + * 두 타입 모두 Radix Checkbox 기반의 단일 on/off 토글로 구현한다 — + * 라디오의 그룹 배타 선택은 사용하는 쪽에서 조합한다. + */ +const selectorVariants = cva( + "inline-flex size-[20px] shrink-0 items-center justify-center border-[1.5px] border-border bg-surface transition-colors disabled:pointer-events-none disabled:opacity-50", + { + variants: { + type: { + checkbox: + "rounded-xs data-[state=checked]:border-transparent data-[state=checked]:bg-primary", + radio: + "rounded-full data-[state=checked]:border-[6px] data-[state=checked]:border-primary data-[state=checked]:bg-background", + }, + }, + defaultVariants: { type: "checkbox" }, + }, +); + +interface SelectorProps extends SelectorBaseProps { + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void; + id?: string; + className?: string; +} + +/** + * @example + * + * setValue("a")} /> + */ +export const Selector = ({ + type = "checkbox", + checked, + defaultChecked, + disabled, + onCheckedChange, + id, + className, +}: SelectorProps) => ( + onCheckedChange?.(value === true)} + className={cn(selectorVariants({ type }), className)} + > + {type === "checkbox" && ( + + + + )} + +); diff --git a/packages/ui-web/src/side-rail.stories.tsx b/packages/ui-web/src/side-rail.stories.tsx new file mode 100644 index 00000000..e34cdcd4 --- /dev/null +++ b/packages/ui-web/src/side-rail.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Compass, Home, LayoutGrid, MapPin, Upload, User } from "lucide-react"; +import { SideRail } from "./side-rail"; + +const items = [ + { key: "home", label: "홈", icon: }, + { key: "explore", label: "탐색", icon: }, + { key: "upload", label: "업로드", icon: }, + { key: "dex", label: "도감", icon: }, + { key: "profile", label: "프로필", icon: }, +]; + +const meta = { + title: "Components/SideRail", + component: SideRail, + args: { + items, + activeKey: "home", + logo: ( + + + + ), + }, + argTypes: { + activeKey: { control: "select", options: items.map((i) => i.key) }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/ui-web/src/side-rail.tsx b/packages/ui-web/src/side-rail.tsx new file mode 100644 index 00000000..504f47ed --- /dev/null +++ b/packages/ui-web/src/side-rail.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { cn } from "./lib/utils"; + +export interface SideRailItem { + key: string; + label: string; + icon: ReactNode; +} + +interface SideRailProps { + items: SideRailItem[]; + activeKey?: string; + onSelect?: (key: string) => void; + /** 상단 로고 슬롯 (40×40) */ + logo?: ReactNode; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap SideRail v3" (node 13288:527) — 웹 사이드 내비 (w 72). + * 로고/아이콘/라벨은 도메인이므로 슬롯과 items로 주입한다. + * + * @example + * } + * items={[{ key: "home", label: "홈", icon: }, ...]} + * activeKey="home" + * onSelect={navigate} + * /> + */ +export const SideRail = ({ + items, + activeKey, + onSelect, + logo, + className, +}: SideRailProps) => ( + +); diff --git a/packages/ui-web/src/switch.stories.tsx b/packages/ui-web/src/switch.stories.tsx new file mode 100644 index 00000000..ada03d48 --- /dev/null +++ b/packages/ui-web/src/switch.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Switch } from "./switch"; + +const meta = { + title: "Components/Switch", + component: Switch, + args: { disabled: false }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +/** off/on × default/disabled — Figma variant와 1:1 */ +export const AllVariants: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/packages/ui-web/src/switch.tsx b/packages/ui-web/src/switch.tsx new file mode 100644 index 00000000..f90eb664 --- /dev/null +++ b/packages/ui-web/src/switch.tsx @@ -0,0 +1,40 @@ +import { Switch as SwitchPrimitive } from "radix-ui"; +import type { SwitchBaseProps } from "@fillmap/design-tokens"; +import { cn } from "./lib/utils"; + +interface SwitchProps extends SwitchBaseProps { + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void; + id?: string; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap Switch" (node 13430:695) — 토글 (36×20). + * Radix Switch 기반 (shadcn 패턴). + * + * @example + * + */ +export const Switch = ({ + checked, + defaultChecked, + disabled, + onCheckedChange, + id, + className, +}: SwitchProps) => ( + + + +); diff --git a/packages/ui-web/src/toast.stories.tsx b/packages/ui-web/src/toast.stories.tsx new file mode 100644 index 00000000..ac73bc99 --- /dev/null +++ b/packages/ui-web/src/toast.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Toast } from "./toast"; + +const meta = { + title: "Components/Toast", + component: Toast, + args: { + variant: "dark", + title: "업로드 전 최종 확인", + description: "AI 처리가 끝나면 미리보기에서 확인한 뒤 지도에 게시돼요", + }, + argTypes: { + variant: { control: "select", options: ["dark", "light"] }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +}; + +/** 다크/라이트 — Figma Style과 1:1 */ +export const AllVariants: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/packages/ui-web/src/toast.tsx b/packages/ui-web/src/toast.tsx new file mode 100644 index 00000000..1306fc1f --- /dev/null +++ b/packages/ui-web/src/toast.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; +import type { ToastBaseProps } from "@fillmap/design-tokens"; +import { cn } from "./lib/utils"; + +interface ToastProps extends ToastBaseProps { + title: string; + description?: string; + /** dark 스타일 좌측 아이콘 슬롯. 기본 "i" 원형 배지 */ + icon?: ReactNode; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap Toast" (node 13405:708) — 안내 배너 (다크/라이트). + * 프레젠테이셔널 쉘 — 표시/사라짐 타이밍은 사용하는 쪽(sonner 등)에서 제어한다. + * + * @example + * + * + */ +export const Toast = ({ + variant = "dark", + title, + description, + icon, + className, +}: ToastProps) => + variant === "dark" ? ( +
+ + {icon ?? "i"} + +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ ) : ( +
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+ ); diff --git a/packages/ui-web/src/video-row.stories.tsx b/packages/ui-web/src/video-row.stories.tsx new file mode 100644 index 00000000..5307547a --- /dev/null +++ b/packages/ui-web/src/video-row.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { VideoRow } from "./video-row"; + +const meta = { + title: "Components/VideoRow", + component: VideoRow, + args: { title: "홍대 거리 야경 감성", meta: "조회 214 · 어제" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const List: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/ui-web/src/video-row.tsx b/packages/ui-web/src/video-row.tsx new file mode 100644 index 00000000..a56ce454 --- /dev/null +++ b/packages/ui-web/src/video-row.tsx @@ -0,0 +1,53 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "./lib/utils"; + +interface VideoRowProps extends ButtonHTMLAttributes { + title: string; + /** 메타 텍스트 (예: "조회 214 · 어제") */ + meta?: string; + thumbnailSrc?: string; +} + +/** + * SOURCE: Figma "FeelMap VideoRow" (node 13431:704) — 영상 리스트 행 (썸네일 88×56). + * + * @example + * + */ +export const VideoRow = ({ + title, + meta, + thumbnailSrc, + className, + type = "button", + ...props +}: VideoRowProps) => ( + +); diff --git a/packages/ui-web/src/zoom-control.stories.tsx b/packages/ui-web/src/zoom-control.stories.tsx new file mode 100644 index 00000000..46843747 --- /dev/null +++ b/packages/ui-web/src/zoom-control.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ZoomControl } from "./zoom-control"; + +const meta = { + title: "Components/ZoomControl", + component: ZoomControl, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/ui-web/src/zoom-control.tsx b/packages/ui-web/src/zoom-control.tsx new file mode 100644 index 00000000..5452d28e --- /dev/null +++ b/packages/ui-web/src/zoom-control.tsx @@ -0,0 +1,41 @@ +import { Minus, Plus } from "lucide-react"; +import { cn } from "./lib/utils"; + +interface ZoomControlProps { + onZoomIn?: () => void; + onZoomOut?: () => void; + className?: string; +} + +/** + * SOURCE: Figma "FeelMap ZoomControl" (node 13135:541) — 웹 지도 줌 컨트롤 (40×96). + * + * @example + * + */ +export const ZoomControl = ({ onZoomIn, onZoomOut, className }: ZoomControlProps) => ( +
+ + + +
+); diff --git a/packages/ui-web/tsconfig.json b/packages/ui-web/tsconfig.json new file mode 100644 index 00000000..02d2564b --- /dev/null +++ b/packages/ui-web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "skipLibCheck": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", ".storybook", "tailwind.config.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7fca252..d4854dfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + lucide-react: + specifier: ^1.24.0 + version: 1.24.0(react@19.2.7) + radix-ui: + specifier: ^1.6.2 + version: 1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 From 7ae8e6f944e6fe8d625219adf3e8024209defa07 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 14 Jul 2026 20:32:54 +0900 Subject: [PATCH 003/281] =?UTF-8?q?MSG-109=20chore:=20=ED=81=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=BD=94=EB=93=9C=20=EC=95=A1=EC=85=98=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/claude-review.yml | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 00000000..997e3e6f --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,48 @@ +name: Claude PR Review + +on: + pull_request: + types: [opened, reopened, ready_for_review, synchronize] + +jobs: + claude-review: + # 드래프트 PR은 리뷰하지 않음 + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + 이 PR을 꼼꼼하게 코드 리뷰해줘. 모든 리뷰는 한국어로 작성해줘. + + 리뷰 관점: + 1. **버그 및 로직 오류** - 잘못된 조건문, 엣지 케이스 누락, null/undefined 처리 누락 + 2. **React/TypeScript 베스트 프랙티스** - 불필요한 리렌더링, 잘못된 훅 사용, any 타입 남용, 타입 안정성 + 3. **성능** - 무거운 연산의 메모이제이션 누락, 불필요한 의존성, 번들 크기 영향 + 4. **가독성 및 유지보수성** - 네이밍, 중복 코드, 컴포넌트 분리 + 5. **보안** - XSS 가능성, 민감 정보 노출 + + 리뷰 방법: + - 구체적인 문제가 있는 라인에는 인라인 코멘트를 남겨줘 + - 심각도를 표시해줘 (🔴 반드시 수정 / 🟡 권장 / 🟢 사소한 제안) + - 문제 지적 시 가능하면 수정 예시 코드를 함께 제시해줘 + - 마지막에 전체 요약 코멘트를 남겨줘 (잘한 점 + 주요 이슈 정리) + - 확실하지 않은 부분은 추측이라고 명시해줘 + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" From dd6ec1bd649599c05a474b41aba8601d5e140891 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 14 Jul 2026 20:48:59 +0900 Subject: [PATCH 004/281] =?UTF-8?q?MSG-109=20chore:=20=EC=95=88=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20css=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/App.css | 184 ---------------------------------- apps/web/src/assets/hero.png | Bin 13057 -> 0 bytes apps/web/src/assets/react.svg | 1 - apps/web/src/assets/vite.svg | 1 - 4 files changed, 186 deletions(-) delete mode 100644 apps/web/src/App.css delete mode 100644 apps/web/src/assets/hero.png delete mode 100644 apps/web/src/assets/react.svg delete mode 100644 apps/web/src/assets/vite.svg diff --git a/apps/web/src/App.css b/apps/web/src/App.css deleted file mode 100644 index f90339d8..00000000 --- a/apps/web/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/apps/web/src/assets/hero.png b/apps/web/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/apps/web/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/src/assets/vite.svg b/apps/web/src/assets/vite.svg deleted file mode 100644 index 5101b674..00000000 --- a/apps/web/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite From 61cb6c128de73b3cee81dec4ac12a3913acb1aee Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Wed, 15 Jul 2026 15:09:09 +0900 Subject: [PATCH 005/281] =?UTF-8?q?MSG-108=20chore:=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=ED=95=98=EB=84=A4=EC=8A=A4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agents/page-builder.md | 32 ++ .claude/agents/page-verifier.md | 32 ++ .claude/agents/ticket-analyst.md | 32 ++ .claude/skills/fillmap-page-dev/SKILL.md | 100 ++++ .claude/skills/page-implementation/SKILL.md | 80 +++ .claude/skills/page-verification/SKILL.md | 74 +++ .claude/skills/ticket-to-spec/SKILL.md | 61 +++ .gitignore | 3 + CLAUDE.md | 14 + apps/web/package.json | 9 +- apps/web/src/lib/utils.test.ts | 20 + apps/web/vite.config.ts | 6 +- docs/decisions/DECISIONS.md | 9 + pnpm-lock.yaml | 554 ++++++++++++++++++++ 14 files changed, 1023 insertions(+), 3 deletions(-) create mode 100644 .claude/agents/page-builder.md create mode 100644 .claude/agents/page-verifier.md create mode 100644 .claude/agents/ticket-analyst.md create mode 100644 .claude/skills/fillmap-page-dev/SKILL.md create mode 100644 .claude/skills/page-implementation/SKILL.md create mode 100644 .claude/skills/page-verification/SKILL.md create mode 100644 .claude/skills/ticket-to-spec/SKILL.md create mode 100644 CLAUDE.md create mode 100644 apps/web/src/lib/utils.test.ts create mode 100644 docs/decisions/DECISIONS.md diff --git a/.claude/agents/page-builder.md b/.claude/agents/page-builder.md new file mode 100644 index 00000000..f782b585 --- /dev/null +++ b/.claude/agents/page-builder.md @@ -0,0 +1,32 @@ +--- +name: page-builder +description: "승인된 스펙을 받아 FillMap 웹 페이지를 구현하는 개발자. 로직 레이어 test-first, 공통 컴포넌트 재사용, 디자인 토큰 준수. 페이지 구현·수정 작업 시 호출." +--- + +# Page Builder — 페이지 구현 + +당신은 FillMap 프론트엔드의 페이지 구현 전문가입니다. 승인된 스펙(`01_spec.md`)을 기획 원문보다 우선하는 유일한 구현 지시서로 삼습니다. + +## 핵심 역할 +1. 스펙의 구현 계획대로 로직 레이어(훅·스토어·스키마·유틸)를 test-first로 구현한다 +2. 뷰를 `@fillmap/ui-web` 공통 컴포넌트로 조립한다 +3. 스펙에 명시된 승격 대상이 있으면 ui-web에 컴포넌트를 추가하고 Storybook 스토리를 함께 작성한다 + +## 작업 원칙 +- 구현 컨벤션(디렉토리 구조, 재사용 규칙, 토큰 규칙, RN 경계 규칙, test-first 절차)은 `.claude/skills/page-implementation/SKILL.md`를 읽고 따른다. 이 스킬이 이 프로젝트의 법이다 +- 스펙에 없는 기능을 추가하지 않는다. 구현 중 스펙의 결함을 발견하면 임의로 해석하지 말고 산출물 보고에 "스펙 이슈"로 기록한다 +- 로직을 먼저, 뷰를 나중에: 수용 기준의 로직 부분을 실패하는 vitest 테스트로 옮긴 뒤 통과시키는 순서를 지킨다. 테스트가 곧 스펙의 번역이므로, 테스트 이름에 수용 기준 문장을 반영한다 +- 모든 테스트·typecheck·lint가 통과한 상태로만 작업을 종료한다 + +## 입력/출력 프로토콜 +- 입력: `_workspace/MSG-{번호}/01_spec.md` (사용자 승인 완료본) +- 출력: 코드 변경(apps/web, 필요 시 packages/ui-web) + `_workspace/MSG-{번호}/02_build_report.md` (변경 파일 목록, 테스트 결과, 스펙 이슈, 트레이드오프 판단) +- 이전 빌드 리포트가 존재하면(재실행): 검증 리포트(`03_verify_report.md`)의 실패 항목만 수정한다. 통과한 부분을 다시 건드리지 않는다 + +## 에러 핸들링 +- 테스트가 구현 후에도 실패하면: 구현이 아니라 테스트가 틀렸는지 먼저 검토한다. 테스트를 스펙과 다르게 고쳐서 통과시키는 것은 금지 — 스펙 이슈로 보고한다 +- 필요한 공통 컴포넌트가 없고 스펙에도 승격 계획이 없으면: 페이지 로컬에 임시 구현하지 말고, 빌드 리포트에 "승격 필요" 항목으로 기록하고 해당 부분을 제외한 나머지를 완성한다 + +## 협업 +- 상류: ticket-analyst의 스펙을 입력으로 받는다 +- 하류: page-verifier가 이 결과물을 스펙의 수용 기준으로 검증한다 diff --git a/.claude/agents/page-verifier.md b/.claude/agents/page-verifier.md new file mode 100644 index 00000000..804259ba --- /dev/null +++ b/.claude/agents/page-verifier.md @@ -0,0 +1,32 @@ +--- +name: page-verifier +description: "구현 결과를 스펙의 수용 기준으로 검증하는 QA. 테스트·typecheck·lint 실행, 토큰/재사용 규칙 위반 감사, dev 서버 브라우저 실동작 확인. 페이지 검증·QA 요청 시 호출." +--- + +# Page Verifier — 수용 기준 기반 검증 + +당신은 FillMap 프론트엔드의 QA 전문가입니다. 구현자의 자기 보고를 신뢰하지 않고, 스펙의 수용 기준을 직접 실행·관찰하여 판정합니다. + +## 핵심 역할 +1. 자동 검증: vitest, typecheck, lint 실행 +2. 규칙 감사: 토큰 규칙(hex/px 리터럴), 재사용 규칙(중복 UI), RN 경계 규칙(window/localStorage/라우터 직접 참조) 위반을 코드에서 직접 탐지 +3. 실동작 검증: dev 서버를 띄우고 수용 기준을 하나씩 브라우저에서 확인 + +## 작업 원칙 +- 검증 절차와 리포트 템플릿은 `.claude/skills/page-verification/SKILL.md`를 읽고 따른다 +- 판정은 수용 기준 단위로 한다 — 전체 인상("대체로 잘 됨")이 아니라 기준별 통과/실패/확인불가 +- 존재 확인이 아니라 경계면 교차 비교: "테스트 파일이 있다"가 아니라 "테스트가 수용 기준 N번을 실제로 커버한다"를 확인한다. 빌드 리포트의 주장과 실제 코드를 대조한다 +- 확인 불가 항목(예: 백엔드 미구현으로 API 연동 확인 불가)은 실패가 아니라 "확인불가 + 사유"로 분류한다 — 거짓 실패는 거짓 통과만큼 해롭다 + +## 입력/출력 프로토콜 +- 입력: `_workspace/MSG-{번호}/01_spec.md`(수용 기준) + `02_build_report.md`(변경 내역) +- 출력: `_workspace/MSG-{번호}/03_verify_report.md` (기준별 판정표 + 위반 목록 + 재작업 지시) +- 재검증 시(이전 리포트 존재): 이전에 실패했던 항목을 우선 확인하되, 수정이 다른 기준을 깨지 않았는지 회귀 확인도 수행한다 + +## 에러 핸들링 +- dev 서버가 뜨지 않으면: 1회 재시도 후, 실패 시 자동 검증·규칙 감사 결과만으로 리포트를 작성하고 실동작 검증 항목을 "확인불가"로 명시한다 +- 테스트 실행 자체가 불가능하면(설정 깨짐 등): 검증을 중단하지 말고 원인을 리포트 최상단에 기록한 뒤 나머지 검증을 진행한다 + +## 협업 +- 상류: page-builder의 구현 결과를 검증한다 +- 리포트의 실패 항목은 page-builder 재실행의 입력이 된다 (오케스트레이터가 최대 2회 재작업 루프 관리) diff --git a/.claude/agents/ticket-analyst.md b/.claude/agents/ticket-analyst.md new file mode 100644 index 00000000..21702c69 --- /dev/null +++ b/.claude/agents/ticket-analyst.md @@ -0,0 +1,32 @@ +--- +name: ticket-analyst +description: "지라 티켓(MSG-xxx) 텍스트 기획을 수용 기준 + 구현 계획 스펙으로 변환하는 분석가. 티켓 착수, 기획 해석, 스펙 작성 시 호출." +--- + +# Ticket Analyst — 티켓 해석 및 SDD 스펙 작성 + +당신은 FillMap 프론트엔드의 기획 분석 전문가입니다. 지라 티켓의 텍스트 기획을 구현 가능한 스펙으로 변환합니다. + +## 핵심 역할 +1. 티켓 텍스트에서 요구사항을 추출하고, 모호한 지점을 명시적 질문 목록으로 분리한다 +2. 검증 가능한 수용 기준(acceptance criteria) 체크리스트를 작성한다 +3. 코드베이스 현황(기존 컴포넌트·훅·라우트)과 대조하여 구현 계획을 세운다 + +## 작업 원칙 +- 스펙 작성 절차와 출력 템플릿은 `.claude/skills/ticket-to-spec/SKILL.md`를 읽고 따른다 +- 수용 기준은 "확인 가능한 문장"으로만 쓴다 — "잘 동작한다"(불가) vs "반경 필터 적용 시 범위 밖 매물이 목록에서 사라진다"(가능) +- 티켓에 없는 요구사항을 발명하지 않는다. 합리적 추정이 필요하면 스펙에 "추정" 표시를 남겨 사용자 확인 대상으로 만든다 +- 구현 계획 수립 전 반드시 `packages/ui-web/src/index.ts`(컴포넌트 인벤토리)와 `apps/web/src/` 현황을 확인한다 — 계획에 "무엇을 재사용하고 무엇이 없는지"가 들어가야 한다 + +## 입력/출력 프로토콜 +- 입력: 티켓 번호 + 기획 텍스트 (오케스트레이터가 프롬프트로 전달) +- 출력: `_workspace/MSG-{번호}/01_spec.md` (템플릿은 ticket-to-spec 스킬 참조) +- 이전 산출물(`01_spec.md`)이 이미 존재하면: 읽고 사용자 피드백을 반영하여 수정한다. 처음부터 다시 쓰지 않는다 + +## 에러 핸들링 +- 티켓 텍스트가 없거나 빈약해 수용 기준을 3개 이상 도출할 수 없으면, 스펙 대신 "질문 목록"만 출력하고 사용자 확인이 필요함을 보고한다 +- 코드베이스 확인 중 모순(예: 기획이 요구하는 기존 기능이 실제로 없음)을 발견하면 스펙의 "리스크" 섹션에 기록한다 + +## 협업 +- 하류: page-builder가 이 스펙을 구현 지시서로 사용하고, page-verifier가 수용 기준을 검증 체크리스트로 사용한다 +- 스펙은 사용자 승인 후에만 하류로 전달된다 (오케스트레이터가 게이트 관리) diff --git a/.claude/skills/fillmap-page-dev/SKILL.md b/.claude/skills/fillmap-page-dev/SKILL.md new file mode 100644 index 00000000..ea8d7511 --- /dev/null +++ b/.claude/skills/fillmap-page-dev/SKILL.md @@ -0,0 +1,100 @@ +--- +name: fillmap-page-dev +description: "FillMap 페이지 개발 파이프라인 오케스트레이터 — 지라 티켓(MSG-xxx) 해석 → 스펙 승인 → 구현 → 검증 → 커밋을 조율. 티켓 번호 언급(예: 'MSG-123 진행해줘'), 페이지/화면/기능 개발·구현 요청, 기획 텍스트 전달 시 반드시 사용. 후속 작업에도 사용: 페이지 수정·보완·재검증, 스펙만 다시, 검증만 다시, 이전 티켓 결과 개선, 리뷰 반영 등." +--- + +# FillMap Page Dev — 티켓 파이프라인 오케스트레이터 + +지라 티켓 하나를 스펙 → 구현 → 검증 → 커밋으로 완주시키는 파이프라인. 단계별 산출물은 `_workspace/MSG-{번호}/`에 누적된다. + +## 실행 모드: 서브 에이전트 (파이프라인) + +단계가 순차 의존이고(스펙 → 구현 → 검증), 스펙과 구현 사이에 사용자 승인 게이트가 있어 팀 실시간 통신의 이점이 없다. 각 단계는 파일 산출물로 연결된다. + +## 에이전트 구성 + +| 에이전트 | subagent_type | 역할 | 스킬 | 출력 | +|---------|--------------|------|------|------| +| ticket-analyst | ticket-analyst | 티켓 → 스펙 변환 | ticket-to-spec | `_workspace/MSG-{n}/01_spec.md` | +| page-builder | page-builder | 스펙 구현 (test-first) | page-implementation | 코드 + `02_build_report.md` | +| page-verifier | page-verifier | 수용 기준 검증 | page-verification | `03_verify_report.md` | + +각 에이전트 호출 시 `model: "opus"`를 명시한다. 커스텀 타입이 세션에 등록되지 않았으면 `general-purpose`로 대체하되, 프롬프트에 해당 에이전트 정의 파일(`.claude/agents/{name}.md`)을 읽고 따르도록 지시한다. 어느 경우든 프롬프트에 담당 스킬(`.claude/skills/{skill}/SKILL.md`) 준수 지시를 포함한다. + +> **경량 실행:** 티켓이 아주 작을 때(파일 1-2개, 로직 없음 — 예: 문구 수정, 스타일 조정)는 에이전트 위임 없이 메인 스레드가 스킬 절차를 직접 따라도 된다. 단, 수용 기준 정의와 검증 절차는 생략하지 않는다. + +## 워크플로우 + +### Phase 0: 컨텍스트 확인 + +1. 티켓 번호를 파악한다. 번호 없이 기획 텍스트만 오면 사용자에게 티켓 번호를 확인한다 +2. `_workspace/MSG-{번호}/` 존재 여부로 실행 모드 결정: + - **미존재** → 초기 실행: Phase 1부터 + - **존재 + 부분 요청**("검증만 다시", "리뷰 반영") → 해당 Phase만 재실행. 이전 산출물 경로를 에이전트 프롬프트에 포함해 피드백 반영 모드로 실행 + - **존재 + 새 기획 텍스트** → 기존 디렉토리를 `_workspace/MSG-{번호}_prev_{YYYYMMDD}/`로 이동 후 초기 실행 + +### Phase 1: 스펙 (ticket-analyst) + +1. `_workspace/MSG-{번호}/` 생성, 티켓 원문을 `00_ticket.md`로 저장 +2. ticket-analyst 호출 → `01_spec.md` 생성 +3. **사용자 승인 게이트**: 수용 기준·추정·질문을 요약 보고하고 승인을 받는다. 추정/질문에 대한 답을 스펙에 반영한 뒤 다음 단계로. 이 게이트는 생략 불가 — 잘못 해석된 기획으로 구현하면 전체 파이프라인이 재작업이 된다 +4. 승인 후: 티켓 브랜치(`타입/MSG-{번호}-{설명}`)가 없으면 생성하고 체크아웃 + +### Phase 2: 구현 (page-builder) + +1. page-builder 호출, 입력: 승인된 `01_spec.md` +2. 결과: 코드 변경 + `02_build_report.md` +3. 빌드 리포트에 "스펙 이슈"나 "승격 필요"가 있으면 사용자에게 보고하고 처리 방향을 확인한 뒤 진행 + +### Phase 3: 검증 (page-verifier) + +1. page-verifier 호출, 입력: `01_spec.md` + `02_build_report.md` +2. 결과: `03_verify_report.md` +3. 분기: + - **전부 통과** → Phase 4 + - **실패 존재** → 실패 항목을 입력으로 page-builder 재호출(Phase 2) → 재검증. **재작업 루프는 최대 2회** — 2회 후에도 실패하면 사용자에게 현황을 보고하고 판단을 받는다 + - **확인불가 존재** → 사유와 함께 사용자에게 보고, 진행 여부 확인 + +### Phase 4: 커밋 + +1. 검증 리포트 요약과 함께 사용자에게 커밋 의사 확인 +2. 커밋 메시지: `MSG-{번호} {타입}: {설명}` (기존 이력 컨벤션: `MSG-107 feat: 공통 컴포넌트 추가`) +3. 결정 기록 대상(트레이드오프 판단, 테스트가 잡은 버그)이 있으면 `docs/decisions/DECISIONS.md` 갱신을 커밋에 포함 + +## 데이터 흐름 + +``` +티켓 텍스트 → 00_ticket.md + → [ticket-analyst] → 01_spec.md → (사용자 승인) + → [page-builder] → 코드 + 02_build_report.md + → [page-verifier] → 03_verify_report.md + → (실패 시 builder로 루프, 최대 2회) → 커밋 +``` + +`_workspace/`는 커밋하지 않는다(.gitignore). 삭제도 하지 않는다 — 후속 요청("MSG-123 보완해줘")의 컨텍스트다. + +## 에러 핸들링 + +| 상황 | 전략 | +|------|------| +| 에이전트 1회 실패 | 1회 재시도, 재실패 시 해당 단계 산출물 없이 사용자에게 보고 | +| 검증 재작업 2회 초과 | 루프 중단, 실패 항목과 시도 내역을 사용자에게 보고 | +| dev 서버 실행 불가 | 실동작 검증을 "확인불가"로 두고 나머지 검증으로 진행 | +| 스펙-기획 모순 발견 | 삭제/임의 수정 금지, 스펙의 리스크 섹션에 병기 후 사용자 확인 | +| 티켓 번호 불명 | 추측으로 브랜치를 만들지 않고 사용자에게 확인 | + +## 테스트 시나리오 + +### 정상 흐름 +1. 사용자: "MSG-201 진행해줘. 기획: 지도 페이지 — 진입 시 서울 중심 지도 렌더링, 셀 탭 시 바텀시트로 상세 표시" +2. Phase 1: ticket-analyst가 수용 기준 5개(로직 2 + 화면 3) 스펙 생성 → 사용자 승인 +3. Phase 2: page-builder가 스토어 테스트 작성 → 구현 → 페이지 조립 +4. Phase 3: page-verifier 전 항목 통과 +5. Phase 4: `feat/MSG-201-map-page` 브랜치에서 `MSG-201 feat: 지도 페이지 구현` 커밋 +6. 예상 산출물: `_workspace/MSG-201/{00_ticket,01_spec,02_build_report,03_verify_report}.md` + 코드 + +### 에러 흐름 +1. Phase 3에서 수용 기준 2번 실패 (바텀시트가 셀 탭에 반응하지 않음) +2. 실패 항목을 입력으로 page-builder 재호출 → 이벤트 연결 수정 +3. 재검증에서 통과 + 기존 통과 항목 회귀 확인 +4. 결정 기록: "MSG-201: 검증에서 바텀시트 이벤트 미연결 발견 — 브라우저 실동작 확인이 잡음" diff --git a/.claude/skills/page-implementation/SKILL.md b/.claude/skills/page-implementation/SKILL.md new file mode 100644 index 00000000..9c26c085 --- /dev/null +++ b/.claude/skills/page-implementation/SKILL.md @@ -0,0 +1,80 @@ +--- +name: page-implementation +description: "FillMap 웹 페이지 구현 컨벤션 — 디렉토리 구조(FSD), 공통 컴포넌트 재사용 규칙, 디자인 토큰 규칙, RN 대비 경계 규칙, 로직 test-first 절차, ui-web 승격 절차. 페이지·화면·기능 구현, 컴포넌트 작성, 훅/스토어/폼 추가, 기존 페이지 수정 등 apps/web 코드를 작성하는 모든 작업에서 반드시 사용." +--- + +# Page Implementation — 페이지 구현 컨벤션 + +승인된 스펙(`01_spec.md`)을 코드로 옮길 때 따르는 규칙. 규칙의 원 출처는 `docs/DESIGN_SYSTEM.md`(6개조)이며, 이 스킬은 페이지 개발 관점의 실행 절차를 더한다. 충돌 시 DESIGN_SYSTEM.md가 우선한다. + +## 구현 순서 + +수용 기준을 로직과 화면으로 나눠, **로직 먼저 test-first → 뷰 조립 → 연결** 순서로 진행한다. 뷰부터 만들면 로직이 뷰에 끌려 들어가 RN 재사용성과 테스트 가능성을 둘 다 잃는다. + +### 1단계: 로직 레이어 (test-first) + +대상: 훅, zustand 스토어, zod 스키마, 유틸, TanStack Query 옵션. + +1. 스펙의 로직형 수용 기준을 실패하는 vitest 테스트로 옮긴다. 테스트 이름은 수용 기준 문장을 반영한다 — 테스트가 곧 기획의 번역이다 +2. 테스트를 통과하는 최소 구현을 작성한다 +3. 리팩토링 후 전체 테스트 재실행 + +테스트 실행: `pnpm --filter web test run` (watch 모드는 `run` 생략) + +### 2단계: 뷰 조립 + +1. **인벤토리 먼저**: `packages/ui-web/src/index.ts`를 읽고 사용 가능한 컴포넌트를 확인한 뒤 시작한다 +2. 페이지는 ui-web 컴포넌트 + 레이아웃(tailwind)으로 조립한다. 페이지 안에 ui-web과 역할이 겹치는 UI를 새로 만들지 않는다 — 중복 UI는 디자인 변경 시 한쪽만 고쳐지는 사고의 씨앗이다 +3. 스타일 값은 토큰 클래스만 사용한다 (아래 토큰 규칙) + +### 3단계: 연결 및 전체 확인 + +라우트 등록, 로직-뷰 연결 후 `pnpm --filter web test run`, `pnpm --filter web typecheck`, `pnpm lint` 전부 통과 확인. + +## 디렉토리 구조 (FSD) + +`apps/web/src/` 하위 배치 기준 (DESIGN_SYSTEM_SPEC.md의 구조를 따름): + +| 위치 | 담는 것 | 예시 | +|------|--------|------| +| `app/` | 라우팅·프로바이더 | 라우터 설정, QueryClientProvider | +| `pages/` | 페이지 조립(얇은 뷰) | `pages/map/MapPage.tsx` | +| `widgets/` | 페이지 간 공유 조합 블록 | Header, BottomNav 조립체 | +| `features/` | 도메인 기능 (api + model + ui) | `features/cell-record/` | +| `entities/` | 도메인 모델·타입 | `entities/cell/` | +| `shared/` | 웹 전용 훅·유틸·어댑터 | storage 어댑터. **재사용 UI 컴포넌트 금지** | + +디렉토리가 아직 없으면 이 구조대로 생성한다. 페이지 컴포넌트는 조립만 담당하는 얇은 층으로 유지하고, 상태·데이터 로직은 features/entities의 model로 내린다. + +## 토큰 규칙 + +- 색상·크기·타이포는 토큰 클래스만: `bg-primary`, `gap-md`, `text-fm-body` 등. hex/px 리터럴과 Tailwind 임의값(`bg-[#fff]`) 금지 +- 시맨틱 토큰(`primary`, `background`) 우선, 원시 토큰(`blue-500`)은 시맨틱으로 표현 불가할 때만 +- 사용 가능한 토큰 클래스 목록: `docs/DESIGN_SYSTEM.md` 참조 + +## RN 대비 경계 규칙 + +로직 레이어(훅·스토어·스키마·유틸)는 추후 React Native에서 그대로 재사용할 코드다. 다음을 지키면 RN 확장이 파일 이동 수준이 되고, 어기면 전면 리팩토링이 된다: + +- **웹 전용 API 직접 참조 금지**: `window`, `document`, `localStorage`를 로직에서 직접 쓰지 않는다. 필요하면 `shared/`에 어댑터(예: `storage.ts`)를 만들어 경유한다 — RN에서는 어댑터 구현만 교체 +- **라우터 격리**: 훅·스토어가 `react-router`를 직접 import하지 않는다. 네비게이션이 필요한 로직은 콜백을 주입받는다 +- **지도 격리**: `react-kakao-maps-sdk`는 웹 전용이다. 지도 관련 코드는 지도 컴포넌트 경계 안에만 두고, 지도 상태(중심좌표·줌 등)는 플랫폼 중립 스토어로 분리한다 +- **선제적 패키지 생성 금지**: `ui-native`, `packages/core` 등을 미리 만들지 않는다. 경계만 지키면 분리는 필요해질 때 싸게 할 수 있다 + +## ui-web 승격 절차 + +페이지 작업 중 공통 UI가 필요한데 ui-web에 없을 때: + +1. 스펙의 "승격 후보"에 명시된 경우에만 진행한다. 스펙에 없으면 임의로 만들지 말고 빌드 리포트에 "승격 필요"로 기록한다 +2. 승격 시: `packages/design-tokens/src/variants.ts`에 `XxxBaseProps` 추가 → `packages/ui-web/src/xxx.tsx` 구현 → `xxx.stories.tsx` 스토리 작성 → `index.ts` 배럴 익스포트. 기존 컴포넌트(chip.tsx 등)의 스타일을 따른다: JSDoc에 Figma SOURCE 주석 + `@example` +3. 도메인 지식(비즈니스 용어, API 타입)이 필요한 컴포넌트는 승격 대상이 아니다 — features에 남긴다 + +## 결정 기록 + +구현 중 트레이드오프 판단(라이브러리 선택, 구조 결정, 스펙 해석)이 있었거나 테스트가 실제 버그를 잡았으면, `docs/decisions/DECISIONS.md`에 한 줄 추가한다. 형식은 해당 파일 상단 참조. 이 기록은 이후 같은 판단의 반복을 막고, 프로젝트 의사결정의 근거를 남긴다. + +## 완료 조건 + +- 로직형 수용 기준마다 대응하는 테스트가 존재하고 통과 +- `pnpm --filter web test run`, `pnpm --filter web typecheck`, `pnpm lint` 모두 통과 +- `_workspace/MSG-{번호}/02_build_report.md` 작성: 변경 파일 목록, 기준별 테스트 매핑, 스펙 이슈, 결정 기록 여부 diff --git a/.claude/skills/page-verification/SKILL.md b/.claude/skills/page-verification/SKILL.md new file mode 100644 index 00000000..02481511 --- /dev/null +++ b/.claude/skills/page-verification/SKILL.md @@ -0,0 +1,74 @@ +--- +name: page-verification +description: "구현된 페이지를 스펙의 수용 기준으로 검증하는 절차 — vitest·typecheck·lint 실행, 토큰/재사용/RN 경계 규칙 위반 감사, dev 서버 브라우저 실동작 확인, 검증 리포트 작성. 페이지 검증, QA, 동작 확인, 재검증, 커밋 전 점검 요청 시 사용." +--- + +# Page Verification — 수용 기준 기반 검증 + +구현 결과를 스펙(`01_spec.md`)의 수용 기준으로 판정한다. 판정 단위는 항상 개별 기준이다 — "전반적으로 잘 됨"은 검증이 아니다. + +## 절차 + +### 1. 자동 검증 + +``` +pnpm --filter web test run +pnpm --filter web typecheck +pnpm lint +``` + +셋 중 하나라도 실패하면 이후 단계를 진행하되, 리포트 최상단에 실패를 명시한다. + +### 2. 규칙 감사 (코드 직접 확인) + +빌드 리포트의 주장을 믿지 말고 변경된 파일을 직접 확인한다: + +- **토큰 위반**: 변경 파일에서 hex 리터럴(`#[0-9a-fA-F]{3,8}`), Tailwind 임의값(`-[#`, `-[0-9+px]`) 검색. 단, variant 정의 안의 컴포넌트 고유 치수(`min-w-[60px]` 등)는 허용 (DESIGN_SYSTEM.md 1조) +- **재사용 위반**: 페이지/features에 ui-web 컴포넌트와 역할이 겹치는 UI가 새로 만들어지지 않았는지. `packages/ui-web/src/index.ts` 인벤토리와 대조 +- **RN 경계 위반**: 훅·스토어·스키마·유틸 파일에서 `window.`, `localStorage`, `document.`, `react-router` import 검색. 어댑터 경유 없이 직접 참조하면 위반 +- **테스트-기준 매핑**: 로직형 수용 기준마다 대응하는 테스트가 실제로 존재하고, 테스트가 기준의 내용을 검증하는지(이름만 비슷한 빈 테스트가 아닌지) 확인 + +### 3. 실동작 검증 (브라우저) + +1. `pnpm dev`로 dev 서버 실행 (백그라운드) +2. 화면형 수용 기준을 하나씩 브라우저에서 재현하고 관찰한다. 브라우저 자동화 도구가 있으면 사용하고, 스크린샷을 `_workspace/MSG-{번호}/screenshots/`에 남긴다 +3. 기준 외 스모크 확인: 콘솔 에러 없음, 페이지 진입·이탈 정상 + +### 4. 리포트 작성 + +`_workspace/MSG-{번호}/03_verify_report.md`: + +```markdown +# MSG-{번호} 검증 리포트 + +## 자동 검증 +| 항목 | 결과 | +|------|------| +| vitest | 통과 (N개) / 실패 | +| typecheck | 통과 / 실패 | +| lint | 통과 / 실패 | + +## 수용 기준 판정 +| # | 기준 | 판정 | 근거 | +|---|------|------|------| +| 1 | {기준} | 통과/실패/확인불가 | {관찰한 것 — 실패 시 재현 조건} | + +## 규칙 감사 +- 토큰: {위반 없음 / 위반 목록(파일:줄)} +- 재사용: {...} +- RN 경계: {...} + +## 재작업 지시 +{실패 항목별로 무엇을 고쳐야 하는지. 없으면 "없음 — 커밋 가능"} +``` + +## 판정 원칙 + +- **통과**: 기준을 직접 관찰(테스트 실행 결과 또는 브라우저 재현)로 확인함 +- **실패**: 기준과 다른 동작을 관찰함 — 재현 조건을 리포트에 기록 +- **확인불가**: 외부 요인(API 미구현, 서버 실행 실패 등)으로 관찰 자체가 불가 — 사유 명시. 확인불가를 실패로 분류하면 불필요한 재작업을, 통과로 분류하면 거짓 완료를 만든다 +- 재검증 시: 이전 실패 항목 우선 확인 + 수정이 다른 기준을 깨지 않았는지 회귀 확인 + +## 결정 기록 + +검증에서 테스트가 실제 버그를 잡았거나(자동 검증 실패 → 수정), 규칙 감사에서 위반이 발견되어 고쳐진 경우, `docs/decisions/DECISIONS.md`에 한 줄 기록한다. 이 기록이 쌓이면 "테스트가 실제로 도움이 된 사례"의 근거가 된다. diff --git a/.claude/skills/ticket-to-spec/SKILL.md b/.claude/skills/ticket-to-spec/SKILL.md new file mode 100644 index 00000000..762cea64 --- /dev/null +++ b/.claude/skills/ticket-to-spec/SKILL.md @@ -0,0 +1,61 @@ +--- +name: ticket-to-spec +description: "지라 티켓(MSG-xxx) 텍스트 기획을 수용 기준 + 구현 계획 스펙(01_spec.md)으로 변환하는 절차. 티켓 착수, 기획 해석, 스펙 작성, 수용 기준 정의, 스펙 수정·보완 요청 시 사용. 구현 코드를 작성하는 스킬이 아님 — 구현은 page-implementation 스킬." +--- + +# Ticket to Spec — 티켓 → 스펙 변환 + +티켓 텍스트를 구현·검증의 유일한 기준이 되는 스펙 문서로 변환한다. 이 스펙이 정확해야 하류(구현·검증)가 흔들리지 않는다 — 파이프라인에서 가장 레버리지가 큰 단계다. + +## 절차 + +1. **티켓 파악**: 티켓 번호와 기획 텍스트를 확인한다. 텍스트가 없으면 사용자에게 요청한다. +2. **코드베이스 대조**: 스펙을 쓰기 전에 반드시 확인한다 — + - `packages/ui-web/src/index.ts` — 사용 가능한 공통 컴포넌트 인벤토리 + - `apps/web/src/` — 기존 라우트, features, entities (재사용/충돌 확인) + - `docs/DESIGN_SYSTEM.md` — 소속 판단 기준 (packages vs features) +3. **수용 기준 도출**: 기획의 각 요구를 검증 가능한 문장으로 변환한다. + - 검증 가능 = 통과/실패를 관찰로 판정할 수 있음. "지도가 잘 보인다"는 불가, "페이지 진입 시 지도가 서울 중심으로 렌더링된다"는 가능 + - 로직 기준(→ vitest 테스트 대상)과 화면 기준(→ 브라우저 확인 대상)을 구분 표기한다 +4. **구현 계획 수립**: 재사용할 컴포넌트, 새로 만들 로직(훅·스토어·스키마), 라우트, 승격 후보를 명시한다. +5. **브랜치 확인**: 현재 브랜치가 해당 티켓 브랜치(`타입/MSG-{번호}-{설명}`)인지 확인하고, 아니면 생성을 계획에 포함한다. 타입은 feat/fix/chore 중 기획 성격에 맞게. + +## 스펙 템플릿 + +`_workspace/MSG-{번호}/01_spec.md`에 다음 구조로 작성한다: + +```markdown +# MSG-{번호}: {제목} + +## 기획 요약 +{티켓 원문의 핵심을 2-4문장으로} + +## 수용 기준 +| # | 기준 | 유형 | 검증 방법 | +|---|------|------|----------| +| 1 | {검증 가능한 문장} | 로직 | vitest | +| 2 | {검증 가능한 문장} | 화면 | 브라우저 | + +## 구현 계획 +- **브랜치**: {타입}/MSG-{번호}-{설명} +- **재사용**: {ui-web 컴포넌트 목록} +- **신규 로직**: {훅/스토어/스키마 + 배치 위치(features/entities)} +- **라우트**: {경로 및 변경} +- **승격 후보**: {ui-web에 없어서 새로 만들어야 하는 공통 UI, 없으면 "없음"} + +## 추정 및 질문 +- {티켓에 없어서 추정한 것 — 사용자 확인 필요 표시} + +## 리스크 +- {기획-코드 모순, 외부 의존(API 미구현 등)} +``` + +## 원칙 + +- **수용 기준은 3개 이상**: 3개를 못 만들면 기획이 빈약한 것이다. 스펙 대신 질문 목록을 출력하고 멈춘다. +- **추정은 숨기지 않는다**: 기획의 빈 곳을 채우는 것은 필요하지만, 반드시 "추정" 섹션에 노출해 사용자 승인 대상으로 만든다. 조용한 추정이 나중에 재작업의 최대 원인이다. +- **수용 기준 ↔ 구현 계획 정합성**: 계획에 있는데 어떤 기준도 커버하지 않는 작업, 기준에 있는데 계획이 없는 요구가 없어야 한다. + +## 완료 조건 + +스펙 파일 저장 후, 사용자에게 수용 기준과 추정/질문을 요약 보고한다. **사용자 승인 전에는 구현 단계로 넘어가지 않는다** — 승인 게이트는 오케스트레이터(fillmap-page-dev)가 관리한다. diff --git a/.gitignore b/.gitignore index 42d7b883..a618cdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ storybook-static/ *.log .DS_Store + +# harness workspace (ticket pipeline artifacts) +_workspace*/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..18141ac4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# FillMap FE + +pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tailwind-preset · ui-web). 디자인 시스템 규칙은 `docs/DESIGN_SYSTEM.md`(6개조), 구조 스펙은 `DESIGN_SYSTEM_SPEC.md`. + +## 하네스: 지라 티켓 기반 페이지 개발 + +**목표:** 지라 티켓(MSG-xxx) 기획을 스펙 → 구현 → 검증 → 커밋으로 완주시킨다. + +**트리거:** 티켓 번호 언급, 페이지/화면/기능 개발·수정·검증 요청 시 `fillmap-page-dev` 스킬을 사용하라. 단순 질문은 직접 응답 가능. + +**변경 이력:** +| 날짜 | 변경 내용 | 대상 | 사유 | +|------|----------|------|------| +| 2026-07-15 | 초기 구성 (에이전트 3 + 스킬 4 + vitest 셋업) | 전체 | - | diff --git a/apps/web/package.json b/apps/web/package.json index c0598ffe..6c8d4d6a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "typecheck": "tsc -b" }, "dependencies": { "@fillmap/design-tokens": "workspace:*", @@ -36,6 +38,7 @@ "@eslint/js": "^10.0.1", "@fillmap/tailwind-preset": "workspace:*", "@tailwindcss/vite": "^4.3.2", + "@testing-library/react": "^16.3.2", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -44,10 +47,12 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", + "jsdom": "^29.1.1", "kakao.maps.d.ts": "^0.1.40", "tailwindcss": "^4.3.2", "typescript": "~6.0.2", "typescript-eslint": "^8.62.0", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^4.1.10" } } diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts new file mode 100644 index 00000000..8a8f8a72 --- /dev/null +++ b/apps/web/src/lib/utils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { cn } from "./utils"; + +describe("cn", () => { + it("조건부 클래스를 병합한다", () => { + const hidden = false as boolean; + expect(cn("flex", hidden && "hidden", "gap-md")).toBe("flex gap-md"); + }); + + it("충돌하는 tailwind 클래스는 뒤의 값이 이긴다", () => { + expect(cn("p-2", "p-4")).toBe("p-4"); + }); + + // 알려진 한계: twMerge 기본 설정은 커스텀 토큰 클래스(p-xs 등)의 충돌을 + // 인식하지 못해 둘 다 유지된다. extendTailwindMerge 설정 전까지의 현재 동작. + // docs/decisions/DECISIONS.md 2026-07-15 항목 참조. + it("커스텀 토큰 클래스 충돌은 병합되지 않는다 (미설정 상태)", () => { + expect(cn("p-xs", "p-xl")).toBe("p-xs p-xl"); + }); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b8865355..cfc6b712 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,5 +1,5 @@ import path from "path"; -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -10,4 +10,8 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + test: { + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + }, }); diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md new file mode 100644 index 00000000..a0d8ac81 --- /dev/null +++ b/docs/decisions/DECISIONS.md @@ -0,0 +1,9 @@ +# 결정 기록 (Decision Log) + +트레이드오프 판단, 테스트가 실제 버그를 잡은 순간, 스펙 해석 결정을 한 줄씩 기록한다. +형식: `| 날짜 | 티켓 | 결정/발견 | 근거 |` + +| 날짜 | 티켓 | 결정/발견 | 근거 | +|------|------|----------|------| +| 2026-07-15 | MSG-108 | 발견: `cn()`(tailwind-merge)이 커스텀 토큰 클래스(`p-xs` vs `p-xl` 등) 충돌을 병합하지 못함 — vitest 셋업 첫 스모크 테스트가 발견. `extendTailwindMerge`로 토큰 스케일 등록 필요(별도 티켓 권장, ui-web의 cn도 동일 이슈) | 기본 twMerge 설정은 Tailwind 기본 스케일만 인식. 토큰 충돌 시 둘 다 DOM에 남아 CSS 순서가 승자를 결정하는 잠재 버그 | +| 2026-07-15 | MSG-108 | 테스트 전략: 로직 레이어(훅·스토어·스키마·유틸)만 test-first, 뷰는 브라우저 실동작 검증 | 뷰는 기획·디자인 변경으로 스펙이 자주 바뀌어 테스트 유지비가 회수율을 초과. 로직은 안정적이고 RN 재사용 대상이라 투자 가치 높음. 뷰-로직 분리로 뷰 테스트 추가는 필요 시점에 저비용 가능(되돌리기 쉬운 결정) | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4854dfd..deca2098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.2 version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/node': specifier: ^24.13.2 version: 24.13.3 @@ -111,6 +114,9 @@ importers: globals: specifier: ^17.7.0 version: 17.7.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 kakao.maps.d.ts: specifier: ^0.1.40 version: 0.1.40 @@ -126,6 +132,9 @@ importers: vite: specifier: ^8.1.1 version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) packages/design-tokens: {} @@ -202,6 +211,21 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -335,6 +359,46 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -561,6 +625,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1681,6 +1754,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -1865,6 +1941,21 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} engines: {node: '>=12', npm: '>=6'} @@ -2002,15 +2093,44 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -2111,6 +2231,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -2155,6 +2278,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2241,6 +2368,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2252,6 +2383,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} @@ -2268,6 +2403,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -2373,6 +2511,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2388,6 +2530,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2469,6 +2614,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2493,6 +2641,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -2675,6 +2827,10 @@ packages: resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2785,6 +2941,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2837,6 +2996,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3003,6 +3171,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -3104,6 +3275,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3178,6 +3353,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3211,6 +3389,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -3431,6 +3612,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3486,6 +3671,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3504,10 +3692,16 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -3567,6 +3761,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.31.15: resolution: {integrity: sha512-7mqCtD28TK5dVdLAQONVa/Do/NBgMH2dxqf49nh6DIKoEWuDg6tkgGBP+dN22VEJVPZa/QqiHomhWNRn4WUNTQ==} engines: {node: '>=8.0.0'} @@ -3586,6 +3783,13 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -3594,10 +3798,21 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3606,6 +3821,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -3760,9 +3983,66 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3773,6 +4053,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3800,6 +4085,13 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3854,6 +4146,26 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4042,6 +4354,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 @@ -4218,6 +4558,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -5280,6 +5622,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@storybook/builder-vite@10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': @@ -5452,6 +5796,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -5625,20 +5979,61 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.1.10': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webcontainer/env@1.1.1': {} accepts@2.0.0: @@ -5724,6 +6119,10 @@ snapshots: baseline-browser-mapping@2.10.42: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -5782,6 +6181,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@5.6.2: {} check-error@2.1.3: {} @@ -5855,12 +6256,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} cssesc@3.0.0: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns@4.4.0: {} debounce-fn@4.0.0: @@ -5871,6 +6284,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dedent@1.7.2: {} deep-eql@5.0.2: {} @@ -5942,6 +6357,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -5952,6 +6369,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6081,6 +6500,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} etag@1.8.1: {} @@ -6118,6 +6541,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -6317,6 +6742,12 @@ snapshots: hono@4.12.28: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -6393,6 +6824,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regexp@3.1.0: {} @@ -6427,6 +6860,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6553,6 +7012,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -6619,6 +7080,8 @@ snapshots: object-treeify@1.1.33: {} + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -6756,6 +7219,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -6777,6 +7244,8 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} picocolors@1.1.1: {} @@ -7060,6 +7529,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -7169,6 +7642,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7179,8 +7654,12 @@ snapshots: source-map@0.6.1: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + stdin-discarder@0.2.2: {} storybook@10.5.0(@types/react@19.2.17)(react@19.2.7): @@ -7243,6 +7722,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + systeminformation@5.31.15: {} tailwind-merge@3.6.0: {} @@ -7253,6 +7734,10 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -7260,14 +7745,30 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.4: {} + tldts-core@7.4.8: {} + + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -7377,8 +7878,52 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 + vitest@4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7387,6 +7932,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} @@ -7402,6 +7952,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} From b0afb2c3b9c002771388fc8a8540e6fb9ed932bb Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Wed, 15 Jul 2026 15:19:26 +0900 Subject: [PATCH 006/281] =?UTF-8?q?MSG-108=20chore:=20=EC=A7=80=EB=9D=BC?= =?UTF-8?q?=20=ED=8B=B0=EC=BC=93=20=EA=B8=B0=EB=B0=98=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=91=EB=8F=99=ED=95=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/ticket-to-spec/SKILL.md | 1 + CLAUDE.md | 1 + docs/TICKET_TEMPLATE.md | 26 ++++++++++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 docs/TICKET_TEMPLATE.md diff --git a/.claude/skills/ticket-to-spec/SKILL.md b/.claude/skills/ticket-to-spec/SKILL.md index 762cea64..cdfc59d0 100644 --- a/.claude/skills/ticket-to-spec/SKILL.md +++ b/.claude/skills/ticket-to-spec/SKILL.md @@ -10,6 +10,7 @@ description: "지라 티켓(MSG-xxx) 텍스트 기획을 수용 기준 + 구현 ## 절차 1. **티켓 파악**: 티켓 번호와 기획 텍스트를 확인한다. 텍스트가 없으면 사용자에게 요청한다. + - 티켓은 보통 `docs/TICKET_TEMPLATE.md` 구조([목적]/[동작 요구]/[제외 범위]/[참고])로 작성된다. [동작 요구]의 문장들이 수용 기준의 1차 후보이고, [제외 범위]는 구현 계획에 포함하면 안 되는 경계다. 템플릿을 따르지 않는 티켓도 같은 절차로 처리하되 추정이 늘어날 뿐이다. 2. **코드베이스 대조**: 스펙을 쓰기 전에 반드시 확인한다 — - `packages/ui-web/src/index.ts` — 사용 가능한 공통 컴포넌트 인벤토리 - `apps/web/src/` — 기존 라우트, features, entities (재사용/충돌 확인) diff --git a/CLAUDE.md b/CLAUDE.md index 18141ac4..8f3862f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,3 +12,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail | 날짜 | 변경 내용 | 대상 | 사유 | |------|----------|------|------| | 2026-07-15 | 초기 구성 (에이전트 3 + 스킬 4 + vitest 셋업) | 전체 | - | +| 2026-07-15 | 티켓 description 템플릿 추가, 스펙 변환 시 템플릿 구조 활용 | docs/TICKET_TEMPLATE.md, skills/ticket-to-spec | 티켓 작성 표준화로 스펙 승인 질문 최소화 | diff --git a/docs/TICKET_TEMPLATE.md b/docs/TICKET_TEMPLATE.md new file mode 100644 index 00000000..449d3fec --- /dev/null +++ b/docs/TICKET_TEMPLATE.md @@ -0,0 +1,26 @@ +# 지라 티켓 Description 템플릿 + +지라 티켓 작성 시 아래 구조를 복사해 사용한다. 이 구조로 쓰면 하네스(fillmap-page-dev)의 +스펙 변환 정확도가 올라가고, 스펙 승인 단계의 질문이 줄어든다. + +``` +[목적] +왜 이 페이지/기능이 필요한지 한두 문장 + +[동작 요구] ← 사용자 관점, "~하면 ~된다" 형태의 관찰 가능한 문장으로 +- 지도 페이지 진입 시 현재 위치 중심으로 지도가 보인다 +- 셀을 탭하면 바텀시트가 올라오고 셀 상세가 표시된다 +- 위치 권한 거부 시 서울 시청 중심으로 폴백한다 + +[제외 범위] +이번 티켓에서 안 하는 것 (예: "검색은 MSG-124에서") + +[참고] +Figma 링크, 관련 티켓 +``` + +작성 요령: +- **동작 요구가 곧 수용 기준이 된다.** "지도 기능 구현" 같은 명사형 대신 행동 단위 문장으로 +- **엣지 케이스(권한 거부, 빈 데이터, 로딩 실패)를 빠뜨리지 않는 것이 가장 중요** — + 기획자만 아는 정보라 여기 없으면 하네스가 추정하거나 질문하게 된다 +- 제외 범위를 쓰면 하네스가 범위를 넘겨짚어 과잉 구현하는 것을 막는다 From e4a23bebb6db7b591a0f62d126a926bf96247138 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Wed, 15 Jul 2026 15:25:40 +0900 Subject: [PATCH 007/281] =?UTF-8?q?MSG-108=20chore:=20=EC=A7=80=EB=9D=BC?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/fillmap-page-dev/SKILL.md | 2 +- .claude/skills/ticket-to-spec/SKILL.md | 2 +- CLAUDE.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/fillmap-page-dev/SKILL.md b/.claude/skills/fillmap-page-dev/SKILL.md index ea8d7511..0c2d0f9c 100644 --- a/.claude/skills/fillmap-page-dev/SKILL.md +++ b/.claude/skills/fillmap-page-dev/SKILL.md @@ -27,7 +27,7 @@ description: "FillMap 페이지 개발 파이프라인 오케스트레이터 — ### Phase 0: 컨텍스트 확인 -1. 티켓 번호를 파악한다. 번호 없이 기획 텍스트만 오면 사용자에게 티켓 번호를 확인한다 +1. 티켓 번호를 파악한다. 번호 없이 기획 텍스트만 오면 사용자에게 티켓 번호를 확인한다. 번호만 오면 티켓 본문은 ticket-analyst가 atlassian MCP로 조회한다 (ticket-to-spec 스킬 절차 1) 2. `_workspace/MSG-{번호}/` 존재 여부로 실행 모드 결정: - **미존재** → 초기 실행: Phase 1부터 - **존재 + 부분 요청**("검증만 다시", "리뷰 반영") → 해당 Phase만 재실행. 이전 산출물 경로를 에이전트 프롬프트에 포함해 피드백 반영 모드로 실행 diff --git a/.claude/skills/ticket-to-spec/SKILL.md b/.claude/skills/ticket-to-spec/SKILL.md index cdfc59d0..3c1f66ab 100644 --- a/.claude/skills/ticket-to-spec/SKILL.md +++ b/.claude/skills/ticket-to-spec/SKILL.md @@ -9,7 +9,7 @@ description: "지라 티켓(MSG-xxx) 텍스트 기획을 수용 기준 + 구현 ## 절차 -1. **티켓 파악**: 티켓 번호와 기획 텍스트를 확인한다. 텍스트가 없으면 사용자에게 요청한다. +1. **티켓 파악**: 티켓 번호와 기획 텍스트를 확인한다. 번호만 있고 텍스트가 없으면 atlassian MCP 도구로 해당 이슈(키: MSG-{번호})의 summary와 description을 조회한다. MCP가 미연결이거나 조회에 실패하면 사용자에게 텍스트를 요청한다 — 티켓 내용을 추측으로 채우지 않는다. - 티켓은 보통 `docs/TICKET_TEMPLATE.md` 구조([목적]/[동작 요구]/[제외 범위]/[참고])로 작성된다. [동작 요구]의 문장들이 수용 기준의 1차 후보이고, [제외 범위]는 구현 계획에 포함하면 안 되는 경계다. 템플릿을 따르지 않는 티켓도 같은 절차로 처리하되 추정이 늘어날 뿐이다. 2. **코드베이스 대조**: 스펙을 쓰기 전에 반드시 확인한다 — - `packages/ui-web/src/index.ts` — 사용 가능한 공통 컴포넌트 인벤토리 diff --git a/CLAUDE.md b/CLAUDE.md index 8f3862f8..6596b115 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,3 +13,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail |------|----------|------|------| | 2026-07-15 | 초기 구성 (에이전트 3 + 스킬 4 + vitest 셋업) | 전체 | - | | 2026-07-15 | 티켓 description 템플릿 추가, 스펙 변환 시 템플릿 구조 활용 | docs/TICKET_TEMPLATE.md, skills/ticket-to-spec | 티켓 작성 표준화로 스펙 승인 질문 최소화 | +| 2026-07-15 | atlassian MCP 연결 — 티켓 번호만으로 지라 본문 조회 | skills/ticket-to-spec, skills/fillmap-page-dev | "MSG-xxx 진행해줘"만으로 파이프라인 시작 가능하게 | From d5ba8118772a34157702b7518e1e5be2682d1006 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Wed, 15 Jul 2026 15:38:07 +0900 Subject: [PATCH 008/281] =?UTF-8?q?MSG-108=20chore:=20=ED=8B=B0=EC=BC=93?= =?UTF-8?q?=20=EB=B2=94=EC=9C=84=20=EB=B0=96=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=88=EC=A7=80=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/page-implementation/SKILL.md | 8 ++++++++ CLAUDE.md | 1 + 2 files changed, 9 insertions(+) diff --git a/.claude/skills/page-implementation/SKILL.md b/.claude/skills/page-implementation/SKILL.md index 9c26c085..8075e748 100644 --- a/.claude/skills/page-implementation/SKILL.md +++ b/.claude/skills/page-implementation/SKILL.md @@ -69,6 +69,14 @@ description: "FillMap 웹 페이지 구현 컨벤션 — 디렉토리 구조(FSD 2. 승격 시: `packages/design-tokens/src/variants.ts`에 `XxxBaseProps` 추가 → `packages/ui-web/src/xxx.tsx` 구현 → `xxx.stories.tsx` 스토리 작성 → `index.ts` 배럴 익스포트. 기존 컴포넌트(chip.tsx 등)의 스타일을 따른다: JSDoc에 Figma SOURCE 주석 + `@example` 3. 도메인 지식(비즈니스 용어, API 타입)이 필요한 컴포넌트는 승격 대상이 아니다 — features에 남긴다 +## 수술적 변경 (Surgical Changes) + +변경된 모든 줄은 스펙의 요구로 소급 가능해야 한다. 티켓과 무관한 diff는 리뷰 비용을 늘리고 회귀 원인 추적을 흐린다: + +- 작업 범위 밖의 코드·주석·포맷을 "개선"하지 않는다. 기존 스타일이 내 취향과 달라도 따른다 +- 내 변경이 만든 고아(안 쓰게 된 import·변수·함수)는 정리한다. 반대로 원래 있던 죽은 코드는 삭제하지 말고 빌드 리포트에 "발견" 항목으로만 기록한다 +- 깨지지 않은 것을 리팩토링하지 않는다. 리팩토링이 필요해 보이면 별도 티켓 제안이 맞다 + ## 결정 기록 구현 중 트레이드오프 판단(라이브러리 선택, 구조 결정, 스펙 해석)이 있었거나 테스트가 실제 버그를 잡았으면, `docs/decisions/DECISIONS.md`에 한 줄 추가한다. 형식은 해당 파일 상단 참조. 이 기록은 이후 같은 판단의 반복을 막고, 프로젝트 의사결정의 근거를 남긴다. diff --git a/CLAUDE.md b/CLAUDE.md index 6596b115..5f96bbec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,3 +14,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail | 2026-07-15 | 초기 구성 (에이전트 3 + 스킬 4 + vitest 셋업) | 전체 | - | | 2026-07-15 | 티켓 description 템플릿 추가, 스펙 변환 시 템플릿 구조 활용 | docs/TICKET_TEMPLATE.md, skills/ticket-to-spec | 티켓 작성 표준화로 스펙 승인 질문 최소화 | | 2026-07-15 | atlassian MCP 연결 — 티켓 번호만으로 지라 본문 조회 | skills/ticket-to-spec, skills/fillmap-page-dev | "MSG-xxx 진행해줘"만으로 파이프라인 시작 가능하게 | +| 2026-07-15 | 수술적 변경 원칙 추가 (범위 밖 코드 불간섭, 고아 정리, 기존 죽은 코드는 보고만) | skills/page-implementation | 외부 코딩 가이드에서 하네스에 없던 원칙만 선별 흡수 | From 83a14157f6a3cc33bb251bc224a9b6c71f2e23fa Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Wed, 15 Jul 2026 16:22:29 +0900 Subject: [PATCH 009/281] =?UTF-8?q?MSG-108=20fix:=20PR=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=BD=94=EB=A9=98=ED=8A=B8=EA=B0=80=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/claude-review.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 997e3e6f..1dea1de2 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -19,12 +19,15 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 1 + # git diff로 베이스 브랜치와 비교하려면 전체 히스토리 필요 (얕은 클론이면 실패) + fetch-depth: 0 - name: Run Claude Code Review uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # 진행 상황 추적 코멘트를 액션이 직접 게시 — 도구 호출이 실패해도 결과가 PR에 남는 안전망 + track_progress: true prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} @@ -39,10 +42,11 @@ jobs: 5. **보안** - XSS 가능성, 민감 정보 노출 리뷰 방법: - - 구체적인 문제가 있는 라인에는 인라인 코멘트를 남겨줘 + - 변경된 파일은 diff만 보지 말고 Read로 주변 코드까지 확인한 뒤 판단해줘 + - 구체적인 문제가 있는 라인에는 mcp__github_inline_comment__create_inline_comment 도구로 인라인 코멘트를 남겨줘 - 심각도를 표시해줘 (🔴 반드시 수정 / 🟡 권장 / 🟢 사소한 제안) - 문제 지적 시 가능하면 수정 예시 코드를 함께 제시해줘 - - 마지막에 전체 요약 코멘트를 남겨줘 (잘한 점 + 주요 이슈 정리) + - 마지막에 전체 요약 코멘트를 반드시 `gh pr comment` 명령으로 게시해줘 (잘한 점 + 주요 이슈 정리). 최종 응답 텍스트로만 쓰고 끝내면 아무도 못 본다 - 확실하지 않은 부분은 추측이라고 명시해줘 claude_args: | - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" + --allowedTools "mcp__github_inline_comment__create_inline_comment,Read,Grep,Glob,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*)" From 9a6cb478d5c1927d91c1e6d3a868bc752c4b9bcd Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Thu, 16 Jul 2026 14:19:33 +0900 Subject: [PATCH 010/281] =?UTF-8?q?MSG-110=20feat:=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/router.tsx | 19 +++++++++++++++++++ apps/web/src/app/routes.test.ts | 24 ++++++++++++++++++++++++ apps/web/src/app/routes.ts | 21 +++++++++++++++++++++ apps/web/src/main.tsx | 5 +++-- 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/router.tsx create mode 100644 apps/web/src/app/routes.test.ts create mode 100644 apps/web/src/app/routes.ts diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 00000000..1b41fb0f --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -0,0 +1,19 @@ +import { createBrowserRouter } from "react-router-dom"; +import App from "@/App"; +import { AppLayout } from "@/app/layouts/AppLayout"; +import { ROUTES } from "@/app/routes"; +import { PlaceholderPage } from "@/pages/placeholder/PlaceholderPage"; + +export const router = createBrowserRouter([ + { + element: , + children: [ + // TODO: MSG-110 맵 홈 구현 시 데모 페이지(App)를 대체 + { path: ROUTES.home, element: }, + { path: ROUTES.explore, element: }, + { path: ROUTES.upload, element: }, + { path: ROUTES.dex, element: }, + { path: ROUTES.profile, element: }, + ], + }, +]); diff --git a/apps/web/src/app/routes.test.ts b/apps/web/src/app/routes.test.ts new file mode 100644 index 00000000..bded1830 --- /dev/null +++ b/apps/web/src/app/routes.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { ROUTES, getActiveNavKey } from "./routes"; + +describe("getActiveNavKey", () => { + it("루트 경로는 home을 반환한다", () => { + expect(getActiveNavKey("/")).toBe("home"); + }); + + it("각 섹션 경로는 해당 네비 키를 반환한다", () => { + expect(getActiveNavKey(ROUTES.explore)).toBe("explore"); + expect(getActiveNavKey(ROUTES.upload)).toBe("upload"); + expect(getActiveNavKey(ROUTES.dex)).toBe("dex"); + expect(getActiveNavKey(ROUTES.profile)).toBe("profile"); + }); + + it("섹션 하위 경로도 해당 네비 키를 반환한다", () => { + expect(getActiveNavKey("/explore/123")).toBe("explore"); + expect(getActiveNavKey("/profile/settings")).toBe("profile"); + }); + + it("알 수 없는 경로는 undefined를 반환한다", () => { + expect(getActiveNavKey("/unknown")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/app/routes.ts b/apps/web/src/app/routes.ts new file mode 100644 index 00000000..9d4421a2 --- /dev/null +++ b/apps/web/src/app/routes.ts @@ -0,0 +1,21 @@ +/** 페이지 경로의 단일 출처 — 라우터 등록과 네비게이션 모두 이 상수를 사용한다 */ +export const ROUTES = { + home: "/", + explore: "/explore", + upload: "/upload", + dex: "/dex", + profile: "/profile", +} as const; + +export type NavKey = keyof typeof ROUTES; + +/** 현재 pathname이 속한 네비 섹션 키를 반환한다. 매칭되는 섹션이 없으면 undefined */ +export const getActiveNavKey = (pathname: string): NavKey | undefined => { + if (pathname === ROUTES.home) return "home"; + const entries = Object.entries(ROUTES) as [NavKey, string][]; + return entries.find( + ([key, path]) => + key !== "home" && + (pathname === path || pathname.startsWith(`${path}/`)), + )?.[0]; +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 97188bc2..2b6204b5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,10 +1,11 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { RouterProvider } from 'react-router-dom' import './styles/globals.css' -import App from './App.tsx' +import { router } from './app/router.tsx' createRoot(document.getElementById('root')!).render( - + , ) From f64b0355bbab284b158c72c0e7565e2519147af8 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Thu, 16 Jul 2026 14:19:52 +0900 Subject: [PATCH 011/281] =?UTF-8?q?MSG-110=20feat:=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/layouts/AppLayout.tsx | 12 +++++++ .../src/pages/placeholder/PlaceholderPage.tsx | 7 +++++ .../src/widgets/side-rail-nav/SideRailNav.tsx | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 apps/web/src/app/layouts/AppLayout.tsx create mode 100644 apps/web/src/pages/placeholder/PlaceholderPage.tsx create mode 100644 apps/web/src/widgets/side-rail-nav/SideRailNav.tsx diff --git a/apps/web/src/app/layouts/AppLayout.tsx b/apps/web/src/app/layouts/AppLayout.tsx new file mode 100644 index 00000000..6bc95af4 --- /dev/null +++ b/apps/web/src/app/layouts/AppLayout.tsx @@ -0,0 +1,12 @@ +import { Outlet } from "react-router-dom"; +import { SideRailNav } from "@/widgets/side-rail-nav/SideRailNav"; + +/** 웹 공통 셸 — 좌측 SideRail 고정, 나머지 영역에 페이지(Outlet) 렌더링 */ +export const AppLayout = () => ( +
+ +
+ +
+
+); diff --git a/apps/web/src/pages/placeholder/PlaceholderPage.tsx b/apps/web/src/pages/placeholder/PlaceholderPage.tsx new file mode 100644 index 00000000..0ea37a58 --- /dev/null +++ b/apps/web/src/pages/placeholder/PlaceholderPage.tsx @@ -0,0 +1,7 @@ +/** 아직 티켓이 착수되지 않은 섹션의 임시 페이지 — 각 섹션 구현 시 대체 */ +export const PlaceholderPage = ({ title }: { title: string }) => ( +
+

{title}

+

준비 중인 페이지예요

+
+); diff --git a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx new file mode 100644 index 00000000..7eca4bca --- /dev/null +++ b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx @@ -0,0 +1,31 @@ +import { Compass, Home, LayoutGrid, MapPin, Upload, User } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { SideRail, type SideRailItem } from "@fillmap/ui-web"; +import { ROUTES, getActiveNavKey, type NavKey } from "@/app/routes"; + +const items: (SideRailItem & { key: NavKey })[] = [ + { key: "home", label: "홈", icon: }, + { key: "explore", label: "탐색", icon: }, + { key: "upload", label: "업로드", icon: }, + { key: "dex", label: "도감", icon: }, + { key: "profile", label: "프로필", icon: }, +]; + +/** SideRail(ui-web)에 라우터를 연결한 조립 위젯 — 경로 기준 활성 표시 + 클릭 시 이동 */ +export const SideRailNav = () => { + const { pathname } = useLocation(); + const navigate = useNavigate(); + + return ( + + + + } + items={items} + activeKey={getActiveNavKey(pathname)} + onSelect={(key) => navigate(ROUTES[key as NavKey])} + /> + ); +}; From e83180e67f6fe71714d13c5549cf73fbbf703760 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Thu, 16 Jul 2026 19:02:21 +0900 Subject: [PATCH 012/281] =?UTF-8?q?MSG-112=20feat:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/App.tsx | 103 -------------- apps/web/src/app/QueryProvider.tsx | 11 ++ apps/web/src/app/router.tsx | 5 +- apps/web/src/entities/cell/index.ts | 2 + apps/web/src/entities/cell/model/cell.ts | 22 +++ .../web/src/entities/cell/model/mock-cells.ts | 25 ++++ .../map-home/model/cell-viewport.test.ts | 98 +++++++++++++ .../features/map-home/model/cell-viewport.ts | 35 +++++ .../map-home/model/use-cells-query.test.ts | 32 +++++ .../map-home/model/use-cells-query.ts | 19 +++ .../map-home/model/viewport-store.test.ts | 36 +++++ .../features/map-home/model/viewport-store.ts | 31 ++++ apps/web/src/main.tsx | 5 +- apps/web/src/pages/map-home/MapHomePage.tsx | 68 +++++++++ .../pages/map-home/ui/CellSummaryPanel.tsx | 105 ++++++++++++++ apps/web/src/pages/map-home/ui/MapCanvas.tsx | 134 ++++++++++++++++++ .../web/src/pages/map-home/ui/MapControls.tsx | 32 +++++ apps/web/src/shared/geolocation.test.ts | 51 +++++++ apps/web/src/shared/geolocation.ts | 33 +++++ docs/decisions/DECISIONS.md | 3 + 20 files changed, 743 insertions(+), 107 deletions(-) delete mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/app/QueryProvider.tsx create mode 100644 apps/web/src/entities/cell/index.ts create mode 100644 apps/web/src/entities/cell/model/cell.ts create mode 100644 apps/web/src/entities/cell/model/mock-cells.ts create mode 100644 apps/web/src/features/map-home/model/cell-viewport.test.ts create mode 100644 apps/web/src/features/map-home/model/cell-viewport.ts create mode 100644 apps/web/src/features/map-home/model/use-cells-query.test.ts create mode 100644 apps/web/src/features/map-home/model/use-cells-query.ts create mode 100644 apps/web/src/features/map-home/model/viewport-store.test.ts create mode 100644 apps/web/src/features/map-home/model/viewport-store.ts create mode 100644 apps/web/src/pages/map-home/MapHomePage.tsx create mode 100644 apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx create mode 100644 apps/web/src/pages/map-home/ui/MapCanvas.tsx create mode 100644 apps/web/src/pages/map-home/ui/MapControls.tsx create mode 100644 apps/web/src/shared/geolocation.test.ts create mode 100644 apps/web/src/shared/geolocation.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index b17f6190..00000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Button } from "@fillmap/ui-web"; -import { - palette, - semantic, - typography, - type ButtonVariant, -} from "@fillmap/design-tokens"; - -const buttonVariants: ButtonVariant[] = ["primary", "secondary", "danger"]; - -/** 디자인 시스템 동작 확인용 데모 페이지 — 실제 화면 구현 시 pages/로 대체 */ -function App() { - return ( -
-
-

FillMap Design System

-

- packages/design-tokens · tailwind-preset · ui-web 동작 확인 데모 -

-
- -
-

Button — Figma variant 1:1

-
- {buttonVariants.map((v) => ( -
-
- -
-

Colors — Semantic

-
- {Object.entries(semantic).map(([name, hex]) => ( -
-
- {name} - {hex} -
- ))} -
-
- -
-

Colors — Primitives

-
- {Object.entries(palette).map(([name, hex]) => ( -
-
- {name} -
- ))} -
-
- -
-

Typography

-
-

Display · 필맵에서 지금 이 순간을 기록해요

-

Heading · 필맵에서 지금 이 순간을 기록해요

-

Title · 필맵에서 지금 이 순간을 기록해요

-

Base · 필맵에서 지금 이 순간을 기록해요

-

Body Strong · 필맵에서 지금 이 순간을 기록해요

-

Body · 필맵에서 지금 이 순간을 기록해요

-

Label · 필맵에서 지금 이 순간을 기록해요

-

Caption · 필맵에서 지금 이 순간을 기록해요

-
-

- 토큰 {Object.keys(typography).length}종 · Inter Variable -

-
- -
-

Shadow / Radius

-
-
raised · md
-
sheet · lg
-
fab · xl
-
toast · full
-
modal · md
-
-
-
- ); -} - -export default App; diff --git a/apps/web/src/app/QueryProvider.tsx b/apps/web/src/app/QueryProvider.tsx new file mode 100644 index 00000000..86241634 --- /dev/null +++ b/apps/web/src/app/QueryProvider.tsx @@ -0,0 +1,11 @@ +import { type ReactNode, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** 앱 전역 TanStack Query 프로바이더 — 클라이언트를 한 번만 생성해 재마운트에도 유지 */ +export const QueryProvider = ({ children }: { children: ReactNode }) => { + const [queryClient] = useState(() => new QueryClient()); + + return ( + {children} + ); +}; diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 1b41fb0f..445ffe7f 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -1,15 +1,14 @@ import { createBrowserRouter } from "react-router-dom"; -import App from "@/App"; import { AppLayout } from "@/app/layouts/AppLayout"; import { ROUTES } from "@/app/routes"; +import { MapHomePage } from "@/pages/map-home/MapHomePage"; import { PlaceholderPage } from "@/pages/placeholder/PlaceholderPage"; export const router = createBrowserRouter([ { element: , children: [ - // TODO: MSG-110 맵 홈 구현 시 데모 페이지(App)를 대체 - { path: ROUTES.home, element: }, + { path: ROUTES.home, element: }, { path: ROUTES.explore, element: }, { path: ROUTES.upload, element: }, { path: ROUTES.dex, element: }, diff --git a/apps/web/src/entities/cell/index.ts b/apps/web/src/entities/cell/index.ts new file mode 100644 index 00000000..f9675a43 --- /dev/null +++ b/apps/web/src/entities/cell/index.ts @@ -0,0 +1,2 @@ +export type { Cell, LatLng, Bounds } from "./model/cell"; +export { MOCK_CELLS } from "./model/mock-cells"; diff --git a/apps/web/src/entities/cell/model/cell.ts b/apps/web/src/entities/cell/model/cell.ts new file mode 100644 index 00000000..44954361 --- /dev/null +++ b/apps/web/src/entities/cell/model/cell.ts @@ -0,0 +1,22 @@ +/** 위경도 좌표 (플랫폼 중립) */ +export interface LatLng { + lat: number; + lng: number; +} + +/** 지도 뷰포트 경계 — 남서(sw)/북동(ne) 꼭짓점 좌표 */ +export interface Bounds { + sw: LatLng; + ne: LatLng; +} + +/** 격자 도메인 모델 */ +export interface Cell { + id: string; + /** 지역명 + 코드 (예: "홍대입구 A-14") */ + label: string; + /** 격자 중심 좌표 */ + center: LatLng; + /** 격자에 속한 영상 수 */ + videoCount: number; +} diff --git a/apps/web/src/entities/cell/model/mock-cells.ts b/apps/web/src/entities/cell/model/mock-cells.ts new file mode 100644 index 00000000..6bcb223b --- /dev/null +++ b/apps/web/src/entities/cell/model/mock-cells.ts @@ -0,0 +1,25 @@ +import type { Cell } from "./cell"; + +/** + * 서울 일대 mock 격자 데이터. + * 실 API 연동 전까지 뷰포트 매칭·요약 집계 시연을 위한 임시 소스. + * 라벨은 "지역명 + 코드" 형식(Figma 13399-1208 확인), 영상 수는 편차를 두어 배치. + */ +export const MOCK_CELLS: Cell[] = [ + { id: "A-14", label: "홍대입구 A-14", center: { lat: 37.5573, lng: 126.9245 }, videoCount: 138 }, + { id: "A-15", label: "합정 A-15", center: { lat: 37.5495, lng: 126.9137 }, videoCount: 72 }, + { id: "B-07", label: "망원 B-07", center: { lat: 37.5556, lng: 126.9016 }, videoCount: 54 }, + { id: "B-08", label: "연남 B-08", center: { lat: 37.5631, lng: 126.9256 }, videoCount: 91 }, + { id: "C-02", label: "성수 C-02", center: { lat: 37.5446, lng: 127.0559 }, videoCount: 205 }, + { id: "C-03", label: "건대입구 C-03", center: { lat: 37.5402, lng: 127.0702 }, videoCount: 47 }, + { id: "D-01", label: "이태원 D-01", center: { lat: 37.5346, lng: 126.9946 }, videoCount: 119 }, + { id: "D-02", label: "한남 D-02", center: { lat: 37.5344, lng: 127.0016 }, videoCount: 33 }, + { id: "E-05", label: "강남역 E-05", center: { lat: 37.4979, lng: 127.0276 }, videoCount: 176 }, + { id: "E-06", label: "역삼 E-06", center: { lat: 37.5006, lng: 127.0364 }, videoCount: 88 }, + { id: "F-09", label: "잠실 F-09", center: { lat: 37.5133, lng: 127.1 }, videoCount: 64 }, + { id: "F-10", label: "송파 F-10", center: { lat: 37.5145, lng: 127.106 }, videoCount: 21 }, + { id: "G-03", label: "종로 G-03", center: { lat: 37.5729, lng: 126.9793 }, videoCount: 97 }, + { id: "G-04", label: "광화문 G-04", center: { lat: 37.5716, lng: 126.9769 }, videoCount: 142 }, + { id: "H-11", label: "여의도 H-11", center: { lat: 37.5219, lng: 126.9245 }, videoCount: 58 }, + { id: "H-12", label: "노량진 H-12", center: { lat: 37.5136, lng: 126.9425 }, videoCount: 12 }, +]; diff --git a/apps/web/src/features/map-home/model/cell-viewport.test.ts b/apps/web/src/features/map-home/model/cell-viewport.test.ts new file mode 100644 index 00000000..39d12b3f --- /dev/null +++ b/apps/web/src/features/map-home/model/cell-viewport.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { Bounds, Cell } from "@/entities/cell"; +import { + filterCellsInBounds, + summarizeCells, + topCellsByVideo, +} from "./cell-viewport"; + +const cell = (id: string, lat: number, lng: number, videoCount: number): Cell => ({ + id, + label: id, + center: { lat, lng }, + videoCount, +}); + +// 서울 도심을 감싸는 예시 bounds +const bounds: Bounds = { + sw: { lat: 37.5, lng: 126.9 }, + ne: { lat: 37.6, lng: 127.0 }, +}; + +describe("filterCellsInBounds (L1)", () => { + it("중심 좌표가 bounds 안에 있는 격자만 반환한다", () => { + const inside = cell("in", 37.55, 126.95, 10); + const outsideLat = cell("outLat", 37.7, 126.95, 10); + const outsideLng = cell("outLng", 37.55, 127.5, 10); + + const result = filterCellsInBounds([inside, outsideLat, outsideLng], bounds); + + expect(result).toEqual([inside]); + }); + + it("bounds 경계 위의 격자는 포함한다 (경계 포함)", () => { + const onSw = cell("sw", 37.5, 126.9, 1); + const onNe = cell("ne", 37.6, 127.0, 1); + + const result = filterCellsInBounds([onSw, onNe], bounds); + + expect(result).toEqual([onSw, onNe]); + }); +}); + +describe("summarizeCells (L2)", () => { + it("뷰포트 내 격자 수와 영상 수 합계를 정확히 반환한다", () => { + const cells = [cell("a", 0, 0, 5), cell("b", 0, 0, 7), cell("c", 0, 0, 3)]; + + expect(summarizeCells(cells)).toEqual({ cellCount: 3, videoCount: 15 }); + }); +}); + +describe("summarizeCells / filterCellsInBounds 빈 상태 (L4)", () => { + it("뷰포트 내 격자가 없으면 빈 배열과 격자 0·영상 0을 반환한다", () => { + const outside = [cell("far", 40, 130, 100)]; + + const filtered = filterCellsInBounds(outside, bounds); + + expect(filtered).toEqual([]); + expect(summarizeCells(filtered)).toEqual({ cellCount: 0, videoCount: 0 }); + }); +}); + +describe("topCellsByVideo (L3)", () => { + it("영상 수 내림차순으로 최대 3개를 반환한다", () => { + const cells = [ + cell("a", 0, 0, 10), + cell("b", 0, 0, 50), + cell("c", 0, 0, 30), + cell("d", 0, 0, 20), + ]; + + expect(topCellsByVideo(cells).map((c) => c.id)).toEqual(["b", "c", "d"]); + }); + + it("영상 수 동률이면 격자 id 오름차순으로 안정 정렬한다", () => { + const cells = [ + cell("z", 0, 0, 40), + cell("m", 0, 0, 40), + cell("a", 0, 0, 40), + ]; + + expect(topCellsByVideo(cells).map((c) => c.id)).toEqual(["a", "m", "z"]); + }); + + it("격자가 3개 미만이면 있는 만큼만 반환한다", () => { + const cells = [cell("a", 0, 0, 5), cell("b", 0, 0, 9)]; + + expect(topCellsByVideo(cells).map((c) => c.id)).toEqual(["b", "a"]); + }); + + it("원본 배열을 변형하지 않는다", () => { + const cells = [cell("a", 0, 0, 1), cell("b", 0, 0, 2)]; + const snapshot = cells.map((c) => c.id); + + topCellsByVideo(cells); + + expect(cells.map((c) => c.id)).toEqual(snapshot); + }); +}); diff --git a/apps/web/src/features/map-home/model/cell-viewport.ts b/apps/web/src/features/map-home/model/cell-viewport.ts new file mode 100644 index 00000000..856ab7cb --- /dev/null +++ b/apps/web/src/features/map-home/model/cell-viewport.ts @@ -0,0 +1,35 @@ +import type { Bounds, Cell } from "@/entities/cell"; + +/** 뷰포트 요약 수치 */ +export interface CellSummary { + cellCount: number; + videoCount: number; +} + +/** + * 중심 좌표가 bounds(남서~북동) 안(경계 포함)에 있는 격자만 반환한다. [L1] + * 순수 함수 — 지도 SDK/플랫폼에 의존하지 않는다(RN 재사용 대상). + */ +export const filterCellsInBounds = (cells: Cell[], bounds: Bounds): Cell[] => + cells.filter( + ({ center }) => + center.lat >= bounds.sw.lat && + center.lat <= bounds.ne.lat && + center.lng >= bounds.sw.lng && + center.lng <= bounds.ne.lng, + ); + +/** 격자 수와 영상 수 합계를 집계한다. 빈 배열이면 0·0. [L2, L4] */ +export const summarizeCells = (cells: Cell[]): CellSummary => ({ + cellCount: cells.length, + videoCount: cells.reduce((sum, cell) => sum + cell.videoCount, 0), +}); + +/** + * 영상 수 내림차순 상위 n개 격자를 반환한다. 동률이면 id 오름차순 안정 정렬. [L3] + * 원본 배열은 변형하지 않는다. + */ +export const topCellsByVideo = (cells: Cell[], n = 3): Cell[] => + [...cells] + .sort((a, b) => b.videoCount - a.videoCount || a.id.localeCompare(b.id)) + .slice(0, n); diff --git a/apps/web/src/features/map-home/model/use-cells-query.test.ts b/apps/web/src/features/map-home/model/use-cells-query.test.ts new file mode 100644 index 00000000..762df871 --- /dev/null +++ b/apps/web/src/features/map-home/model/use-cells-query.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { MOCK_CELLS } from "@/entities/cell"; +import { cellsQueryOptions, fetchCells } from "./use-cells-query"; + +describe("cells query (L7)", () => { + it("queryKey는 API 교체와 무관한 고정 키 ['cells']다", () => { + expect(cellsQueryOptions().queryKey).toEqual(["cells"]); + }); + + it("queryFn을 통해 격자 목록을 조회하며 반환 타입은 Cell[] 계약을 지킨다", async () => { + const queryFn = cellsQueryOptions().queryFn; + expect(typeof queryFn).toBe("function"); + + const cells = await fetchCells(); + + expect(Array.isArray(cells)).toBe(true); + expect(cells.length).toBeGreaterThan(0); + for (const cell of cells) { + expect(cell).toMatchObject({ + id: expect.any(String), + label: expect.any(String), + center: { lat: expect.any(Number), lng: expect.any(Number) }, + videoCount: expect.any(Number), + }); + } + }); + + it("현재 소스는 mock이며, queryFn 내부 교체만으로 실 API 전환이 가능하다", async () => { + // queryFn(mock)의 결과가 mock 소스와 동일 — 실 API 전환 시 이 함수 내부만 바뀐다 + await expect(fetchCells()).resolves.toEqual(MOCK_CELLS); + }); +}); diff --git a/apps/web/src/features/map-home/model/use-cells-query.ts b/apps/web/src/features/map-home/model/use-cells-query.ts new file mode 100644 index 00000000..354c263a --- /dev/null +++ b/apps/web/src/features/map-home/model/use-cells-query.ts @@ -0,0 +1,19 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { MOCK_CELLS, type Cell } from "@/entities/cell"; + +/** + * 격자 목록 조회 queryFn. [L7] + * 현재는 mock 소스를 반환한다 — 실 API 전환 시 이 함수 내부(fetch/axios 호출)만 교체하면 되고, + * queryKey와 반환 타입(Cell[])은 그대로 유지된다. + */ +export const fetchCells = async (): Promise => MOCK_CELLS; + +/** API 교체와 무관한 고정 쿼리 옵션 (queryKey: ["cells"], 반환 타입: Cell[]) */ +export const cellsQueryOptions = () => + queryOptions({ + queryKey: ["cells"] as const, + queryFn: fetchCells, + }); + +/** 격자 목록 조회 훅 — 뷰는 이 훅으로 로딩/에러/데이터 상태를 받는다 */ +export const useCellsQuery = () => useQuery(cellsQueryOptions()); diff --git a/apps/web/src/features/map-home/model/viewport-store.test.ts b/apps/web/src/features/map-home/model/viewport-store.test.ts new file mode 100644 index 00000000..4ce7c46f --- /dev/null +++ b/apps/web/src/features/map-home/model/viewport-store.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useViewportStore } from "./viewport-store"; + +describe("useViewportStore (L5)", () => { + beforeEach(() => { + useViewportStore.setState(useViewportStore.getInitialState(), true); + }); + + it("초기 상태로 중심·줌·bounds를 보유한다", () => { + const state = useViewportStore.getState(); + + expect(state.center).toHaveProperty("lat"); + expect(state.center).toHaveProperty("lng"); + expect(typeof state.zoom).toBe("number"); + expect(state.bounds).toBeNull(); + }); + + it("setViewport 호출 시 중심(lat/lng)·줌·bounds가 갱신된다", () => { + useViewportStore.getState().setViewport({ + center: { lat: 37.5, lng: 127.0 }, + zoom: 4, + bounds: { + sw: { lat: 37.4, lng: 126.9 }, + ne: { lat: 37.6, lng: 127.1 }, + }, + }); + + const state = useViewportStore.getState(); + expect(state.center).toEqual({ lat: 37.5, lng: 127.0 }); + expect(state.zoom).toBe(4); + expect(state.bounds).toEqual({ + sw: { lat: 37.4, lng: 126.9 }, + ne: { lat: 37.6, lng: 127.1 }, + }); + }); +}); diff --git a/apps/web/src/features/map-home/model/viewport-store.ts b/apps/web/src/features/map-home/model/viewport-store.ts new file mode 100644 index 00000000..0c985309 --- /dev/null +++ b/apps/web/src/features/map-home/model/viewport-store.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import type { Bounds, LatLng } from "@/entities/cell"; +import { SEOUL_CITY_HALL } from "@/shared/geolocation"; + +/** 지도 뷰포트 갱신 페이로드 */ +export interface Viewport { + center: LatLng; + zoom: number; + bounds: Bounds; +} + +interface ViewportState { + center: LatLng; + /** 카카오맵 level 스케일 */ + zoom: number; + /** 지도 준비 전에는 null */ + bounds: Bounds | null; + setViewport: (viewport: Viewport) => void; +} + +/** + * 플랫폼 중립 뷰포트 스토어 — 지도 SDK를 import하지 않는다(RN 경계). [L5] + * 지도 컴포넌트가 이동/줌 이벤트를 setViewport로 밀어 넣고, + * 요약 패널 등은 이 스토어를 구독해 현재 보이는 영역을 파생한다. + */ +export const useViewportStore = create((set) => ({ + center: SEOUL_CITY_HALL, + zoom: 5, + bounds: null, + setViewport: (viewport) => set(viewport), +})); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2b6204b5..4682d17f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client' import { RouterProvider } from 'react-router-dom' import './styles/globals.css' import { router } from './app/router.tsx' +import { QueryProvider } from './app/QueryProvider.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/apps/web/src/pages/map-home/MapHomePage.tsx b/apps/web/src/pages/map-home/MapHomePage.tsx new file mode 100644 index 00000000..24d5d88b --- /dev/null +++ b/apps/web/src/pages/map-home/MapHomePage.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { SearchBar } from "@fillmap/ui-web"; +import { ROUTES } from "@/app/routes"; +import type { LatLng } from "@/entities/cell"; +import { useViewportStore } from "@/features/map-home/model/viewport-store"; +import { SEOUL_CITY_HALL, getCurrentPosition } from "@/shared/geolocation"; +import { MapCanvas, type MapCanvasHandle } from "./ui/MapCanvas"; +import { CellSummaryPanel } from "./ui/CellSummaryPanel"; +import { MapControls } from "./ui/MapControls"; + +/** + * 지도 홈 페이지(`/`) — 카카오맵 배경 + 검색바·요약 패널·우하단 컨트롤 조립(얇은 뷰). + * 상태·데이터 로직은 features/entities로 내리고, 여기서는 배치와 콜백 연결만 담당한다. + */ +export const MapHomePage = () => { + const navigate = useNavigate(); + const setViewport = useViewportStore((s) => s.setViewport); + const mapRef = useRef(null); + const [initialCenter, setInitialCenter] = useState(SEOUL_CITY_HALL); + + // 진입 시 현재 위치로 초기 중심 설정 (권한 거부/실패 시 서울 시청 폴백, S2) + useEffect(() => { + let active = true; + getCurrentPosition().then((coords) => { + if (active) setInitialCenter(coords); + }); + return () => { + active = false; + }; + }, []); + + // 현재 위치 재이동 (폴백 일관성 — L6 어댑터 재사용, S9) + const handleLocate = () => { + getCurrentPosition().then((coords) => mapRef.current?.moveTo(coords)); + }; + + return ( +
+
+ +
+ +
+
+ + navigate(ROUTES.explore)} + onCellSelect={(center) => mapRef.current?.moveTo(center)} + /> +
+ +
+ navigate(ROUTES.upload)} + onLocate={handleLocate} + onZoomIn={() => mapRef.current?.zoomIn()} + onZoomOut={() => mapRef.current?.zoomOut()} + /> +
+
+
+ ); +}; diff --git a/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx b/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx new file mode 100644 index 00000000..81256812 --- /dev/null +++ b/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx @@ -0,0 +1,105 @@ +import { Play } from "lucide-react"; +import { BottomSheet, Button, CellBadge } from "@fillmap/ui-web"; +import type { Cell, LatLng } from "@/entities/cell"; +import { + filterCellsInBounds, + summarizeCells, + topCellsByVideo, +} from "@/features/map-home/model/cell-viewport"; +import { useCellsQuery } from "@/features/map-home/model/use-cells-query"; +import { useViewportStore } from "@/features/map-home/model/viewport-store"; + +interface CellSummaryPanelProps { + /** "전체 보기" 클릭 (탐색으로 이동) */ + onViewAll: () => void; + /** 격자 카드 클릭 (지도를 해당 격자 중심으로 이동) */ + onCellSelect: (center: LatLng) => void; +} + +/** 격자 카드 — 썸네일(재생 아이콘) + 라벨 + "N개 영상", 클릭 시 지도 이동(S7) */ +const CellCard = ({ + cell, + onSelect, +}: { + cell: Cell; + onSelect: (center: LatLng) => void; +}) => ( + +); + +/** + * 지도 위 요약 패널 — 현재 뷰포트 기준 격자·영상 요약과 상위 격자 카드. + * 로딩(S12)·에러(S13)·빈(S6)·정상(S4) 4상태를 처리한다. + */ +export const CellSummaryPanel = ({ + onViewAll, + onCellSelect, +}: CellSummaryPanelProps) => { + const bounds = useViewportStore((s) => s.bounds); + const { data, isLoading, isError, refetch } = useCellsQuery(); + + if (isLoading || !bounds) { + return ( + +

+ 이 지역 정보를 불러오는 중이에요… +

+
+ ); + } + + if (isError) { + return ( + +
+

+ 정보를 불러오지 못했어요 +

+
+
+ ); + } + + const visibleCells = filterCellsInBounds(data ?? [], bounds); + const { cellCount, videoCount } = summarizeCells(visibleCells); + const topCells = topCellsByVideo(visibleCells); + + return ( + + {cellCount === 0 ? ( +

+ 이 지역에는 아직 격자가 없어요. 지도를 움직여 다른 지역을 둘러보세요. +

+ ) : ( +
+ {topCells.map((cell) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/pages/map-home/ui/MapCanvas.tsx b/apps/web/src/pages/map-home/ui/MapCanvas.tsx new file mode 100644 index 00000000..cfd87684 --- /dev/null +++ b/apps/web/src/pages/map-home/ui/MapCanvas.tsx @@ -0,0 +1,134 @@ +import { + forwardRef, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { Map, useKakaoLoader } from "react-kakao-maps-sdk"; +import { Button } from "@fillmap/ui-web"; +import type { LatLng } from "@/entities/cell"; +import type { Viewport } from "@/features/map-home/model/viewport-store"; + +/** 지도 명령 핸들 — 카카오맵 인스턴스 제어를 이 경계 밖으로 노출하지 않고 명령만 공개 */ +export interface MapCanvasHandle { + /** 지정 좌표로 부드럽게 이동 */ + moveTo: (coords: LatLng) => void; + zoomIn: () => void; + zoomOut: () => void; +} + +interface MapCanvasProps { + /** 초기 중심 좌표 (geolocation 결과 반영) */ + center: LatLng; + /** 이동/줌 등으로 뷰포트가 바뀔 때 호출 (스토어 push) */ + onViewportChange: (viewport: Viewport) => void; +} + +const KAKAO_APP_KEY = import.meta.env.VITE_KAKAO_MAP_APP_KEY as + | string + | undefined; + +const DEFAULT_LEVEL = 5; + +/** 카카오맵 Map → 플랫폼 중립 Viewport 추출 */ +const toViewport = (map: kakao.maps.Map): Viewport => { + const center = map.getCenter(); + const bounds = map.getBounds(); + const sw = bounds.getSouthWest(); + const ne = bounds.getNorthEast(); + return { + center: { lat: center.getLat(), lng: center.getLng() }, + zoom: map.getLevel(), + bounds: { + sw: { lat: sw.getLat(), lng: sw.getLng() }, + ne: { lat: ne.getLat(), lng: ne.getLng() }, + }, + }; +}; + +/** + * 카카오맵 경계 컴포넌트 — `react-kakao-maps-sdk` import는 이 파일에만 둔다(RN 경계). + * SDK 로드 실패 시 에러 상태 + 재시도(S3), 이동/줌 이벤트를 onViewportChange로 밀어낸다. + */ +export const MapCanvas = forwardRef( + ({ center, onViewportChange }, ref) => { + // 재시도 시 로더 훅을 다시 태우기 위해 하위 뷰를 remount + const [attempt, setAttempt] = useState(0); + + return ( + setAttempt((n) => n + 1)} + /> + ); + }, +); +MapCanvas.displayName = "MapCanvas"; + +interface KakaoMapViewProps extends MapCanvasProps { + onRetry: () => void; +} + +const KakaoMapView = forwardRef( + ({ center, onViewportChange, onRetry }, ref) => { + const [loading, error] = useKakaoLoader({ appkey: KAKAO_APP_KEY ?? "" }); + const mapRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + moveTo: (coords) => { + const map = mapRef.current; + if (!map) return; + map.panTo(new kakao.maps.LatLng(coords.lat, coords.lng)); + }, + zoomIn: () => { + const map = mapRef.current; + if (!map) return; + map.setLevel(map.getLevel() - 1, { animate: true }); + }, + zoomOut: () => { + const map = mapRef.current; + if (!map) return; + map.setLevel(map.getLevel() + 1, { animate: true }); + }, + }), + [], + ); + + if (error || !KAKAO_APP_KEY) { + return ( +
+
+

+ 지도를 불러오지 못했어요 +

+

+ 네트워크 상태를 확인하고 다시 시도해 주세요 +

+
+
+ ); + } + + return ( + { + mapRef.current = map; + onViewportChange(toViewport(map)); + }} + onIdle={(map) => onViewportChange(toViewport(map))} + /> + ); + }, +); +KakaoMapView.displayName = "KakaoMapView"; diff --git a/apps/web/src/pages/map-home/ui/MapControls.tsx b/apps/web/src/pages/map-home/ui/MapControls.tsx new file mode 100644 index 00000000..46edd3d2 --- /dev/null +++ b/apps/web/src/pages/map-home/ui/MapControls.tsx @@ -0,0 +1,32 @@ +import { Upload } from "lucide-react"; +import { Fab, MapIconButton, ZoomControl } from "@fillmap/ui-web"; + +interface MapControlsProps { + /** 업로드 라우트로 이동 (콜백 주입 — RN 경계) */ + onUpload: () => void; + /** 현재 위치(폴백 시 서울 시청)로 재이동 */ + onLocate: () => void; + onZoomIn: () => void; + onZoomOut: () => void; +} + +/** + * 우하단 컨트롤 스택 — 위→아래: 업로드 FAB → 현재 위치 → 줌 +/−. + * 네비게이션·지도 명령은 모두 콜백 주입(RN 경계). + */ +export const MapControls = ({ + onUpload, + onLocate, + onZoomIn, + onZoomOut, +}: MapControlsProps) => ( +
+ } + onClick={onUpload} + /> + + +
+); diff --git a/apps/web/src/shared/geolocation.test.ts b/apps/web/src/shared/geolocation.test.ts new file mode 100644 index 00000000..f54d8a2c --- /dev/null +++ b/apps/web/src/shared/geolocation.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SEOUL_CITY_HALL, getCurrentPosition } from "./geolocation"; + +const originalGeolocation = navigator.geolocation; + +const setGeolocation = (value: unknown) => { + Object.defineProperty(navigator, "geolocation", { + value, + configurable: true, + writable: true, + }); +}; + +afterEach(() => { + setGeolocation(originalGeolocation); + vi.restoreAllMocks(); +}); + +describe("getCurrentPosition (L6)", () => { + it("위치 권한 거부/조회 실패 시 서울 시청 좌표를 폴백으로 반환한다", async () => { + setGeolocation({ + getCurrentPosition: (_success: PositionCallback, error: PositionErrorCallback) => { + error({ code: 1, message: "denied" } as GeolocationPositionError); + }, + }); + + await expect(getCurrentPosition()).resolves.toEqual(SEOUL_CITY_HALL); + expect(SEOUL_CITY_HALL).toEqual({ lat: 37.5665, lng: 126.978 }); + }); + + it("geolocation API 자체가 없으면 서울 시청 좌표를 반환한다", async () => { + setGeolocation(undefined); + + await expect(getCurrentPosition()).resolves.toEqual(SEOUL_CITY_HALL); + }); + + it("위치 허용 시 조회된 좌표를 반환한다", async () => { + setGeolocation({ + getCurrentPosition: (success: PositionCallback) => { + success({ + coords: { latitude: 37.1234, longitude: 127.5678 }, + } as GeolocationPosition); + }, + }); + + await expect(getCurrentPosition()).resolves.toEqual({ + lat: 37.1234, + lng: 127.5678, + }); + }); +}); diff --git a/apps/web/src/shared/geolocation.ts b/apps/web/src/shared/geolocation.ts new file mode 100644 index 00000000..f6d2ecd5 --- /dev/null +++ b/apps/web/src/shared/geolocation.ts @@ -0,0 +1,33 @@ +/** 위경도 좌표 (LatLng와 구조 호환) — shared는 entities에 의존하지 않으므로 로컬 정의 */ +export interface GeoCoords { + lat: number; + lng: number; +} + +/** 위치 조회 폴백 — 서울 시청 */ +export const SEOUL_CITY_HALL: GeoCoords = { lat: 37.5665, lng: 126.978 }; + +/** + * 현재 위치 조회 어댑터. [L6] + * `navigator.geolocation`은 이 파일 안에서만 참조한다(RN 경계 — RN에서는 구현만 교체). + * 권한 거부·조회 실패·API 미지원 시 서울 시청 좌표로 폴백한다. + */ +export const getCurrentPosition = (): Promise => + new Promise((resolve) => { + const geolocation = + typeof navigator !== "undefined" ? navigator.geolocation : undefined; + + if (!geolocation) { + resolve(SEOUL_CITY_HALL); + return; + } + + geolocation.getCurrentPosition( + (position) => + resolve({ + lat: position.coords.latitude, + lng: position.coords.longitude, + }), + () => resolve(SEOUL_CITY_HALL), + ); + }); diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index a0d8ac81..34c75cd4 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -7,3 +7,6 @@ |------|------|----------|------| | 2026-07-15 | MSG-108 | 발견: `cn()`(tailwind-merge)이 커스텀 토큰 클래스(`p-xs` vs `p-xl` 등) 충돌을 병합하지 못함 — vitest 셋업 첫 스모크 테스트가 발견. `extendTailwindMerge`로 토큰 스케일 등록 필요(별도 티켓 권장, ui-web의 cn도 동일 이슈) | 기본 twMerge 설정은 Tailwind 기본 스케일만 인식. 토큰 충돌 시 둘 다 DOM에 남아 CSS 순서가 승자를 결정하는 잠재 버그 | | 2026-07-15 | MSG-108 | 테스트 전략: 로직 레이어(훅·스토어·스키마·유틸)만 test-first, 뷰는 브라우저 실동작 검증 | 뷰는 기획·디자인 변경으로 스펙이 자주 바뀌어 테스트 유지비가 회수율을 초과. 로직은 안정적이고 RN 재사용 대상이라 투자 가치 높음. 뷰-로직 분리로 뷰 테스트 추가는 필요 시점에 저비용 가능(되돌리기 쉬운 결정) | +| 2026-07-16 | MSG-112 | 결정: `shared/geolocation.ts`가 `entities/cell`의 `LatLng`를 import하지 않고 구조 호환 `GeoCoords`를 로컬 정의 | FSD 최하위 layer인 shared가 상위 entities에 의존하면 layer 규칙 위반. 두 타입은 `{lat,lng}`로 구조 호환이라 대입 가능 — 결합 없이 재사용성 유지 | +| 2026-07-16 | MSG-112 | 결정: 카카오맵 SDK 로드 실패 재시도(S3)를 `attempt` state 키로 하위 뷰 remount하여 `useKakaoLoader`를 재실행 | 로더는 멱등·캐시라 동일 위치 재호출로는 재로딩 안 됨. remount가 SDK 경계 안에서 재시도를 트리거하는 가장 단순한 방법 | +| 2026-07-16 | MSG-112 | 발견: 카카오맵 SDK가 `http://localhost` 출처의 요청을 503으로 거부(콘솔 도메인 등록과 별개) — dev에서 지도를 보려면 https 서빙 필요 | 검증 중 네트워크 계측으로 확인: 같은 키로 https·무Referer 요청은 200, `Referer: http://localhost:5173` 요청은 503. SDK가 프로토콜 상대 URL을 써서 http 페이지에선 http로 요청됨. dev https화(예: vite basic-ssl)는 별도 티켓 권장 | From f68eafe2909c8846de9511e5dd0adf0abdf50903 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Thu, 16 Jul 2026 22:04:26 +0900 Subject: [PATCH 013/281] =?UTF-8?q?MSG-112=20fix:=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/map-home/ui/CellSummaryPanel.tsx | 23 +++++++++++-------- packages/ui-web/src/bottom-sheet.stories.tsx | 12 ++++++++++ packages/ui-web/src/bottom-sheet.tsx | 14 +++++++---- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx b/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx index 81256812..fc4942fc 100644 --- a/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx +++ b/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx @@ -1,5 +1,5 @@ import { Play } from "lucide-react"; -import { BottomSheet, Button, CellBadge } from "@fillmap/ui-web"; +import { BottomSheet, Button } from "@fillmap/ui-web"; import type { Cell, LatLng } from "@/entities/cell"; import { filterCellsInBounds, @@ -27,12 +27,16 @@ const CellCard = ({
+); + /** * 카카오맵 경계 컴포넌트 — `react-kakao-maps-sdk` import는 이 파일에만 둔다(RN 경계). * SDK 로드 실패 시 에러 상태 + 재시도(S3), 이동/줌 이벤트를 onViewportChange로 밀어낸다. + * 키 미설정 시에는 로더를 마운트하지 않아 빈 키로 SDK 요청이 나가지 않는다. */ export const MapCanvas = forwardRef( ({ center, onViewportChange }, ref) => { // 재시도 시 로더 훅을 다시 태우기 위해 하위 뷰를 remount const [attempt, setAttempt] = useState(0); + if (!KAKAO_APP_KEY) { + return setAttempt((n) => n + 1)} />; + } + return ( setAttempt((n) => n + 1)} @@ -69,12 +88,13 @@ export const MapCanvas = forwardRef( MapCanvas.displayName = "MapCanvas"; interface KakaoMapViewProps extends MapCanvasProps { + appkey: string; onRetry: () => void; } const KakaoMapView = forwardRef( - ({ center, onViewportChange, onRetry }, ref) => { - const [loading, error] = useKakaoLoader({ appkey: KAKAO_APP_KEY ?? "" }); + ({ appkey, center, onViewportChange, onRetry }, ref) => { + const [loading, error] = useKakaoLoader({ appkey }); const mapRef = useRef(null); useImperativeHandle( @@ -99,20 +119,8 @@ const KakaoMapView = forwardRef( [], ); - if (error || !KAKAO_APP_KEY) { - return ( -
-
-

- 지도를 불러오지 못했어요 -

-

- 네트워크 상태를 확인하고 다시 시도해 주세요 -

-
-
- ); + if (error) { + return ; } return ( From 194d10cb7186879233e1cc276a090c255f2f2447 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Thu, 16 Jul 2026 22:31:03 +0900 Subject: [PATCH 016/281] =?UTF-8?q?MSG-112=20fix:=20=EC=A4=8C=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=ED=81=B4=EB=9E=A8?= =?UTF-8?q?=ED=95=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/map-home/ui/MapCanvas.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/map-home/ui/MapCanvas.tsx b/apps/web/src/pages/map-home/ui/MapCanvas.tsx index 630712de..209bfcff 100644 --- a/apps/web/src/pages/map-home/ui/MapCanvas.tsx +++ b/apps/web/src/pages/map-home/ui/MapCanvas.tsx @@ -29,6 +29,9 @@ const KAKAO_APP_KEY = import.meta.env.VITE_KAKAO_MAP_APP_KEY as | undefined; const DEFAULT_LEVEL = 5; +// 카카오맵 level 유효 범위 — SDK 내부 클램핑에 기대지 않고 명시한다 +const MIN_LEVEL = 1; +const MAX_LEVEL = 14; /** 카카오맵 Map → 플랫폼 중립 Viewport 추출 */ const toViewport = (map: kakao.maps.Map): Viewport => { @@ -108,12 +111,16 @@ const KakaoMapView = forwardRef( zoomIn: () => { const map = mapRef.current; if (!map) return; - map.setLevel(map.getLevel() - 1, { animate: true }); + map.setLevel(Math.max(MIN_LEVEL, map.getLevel() - 1), { + animate: true, + }); }, zoomOut: () => { const map = mapRef.current; if (!map) return; - map.setLevel(map.getLevel() + 1, { animate: true }); + map.setLevel(Math.min(MAX_LEVEL, map.getLevel() + 1), { + animate: true, + }); }, }), [], From 8fccfc43afd96264293047c5e1fd9ebd01399606 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Thu, 16 Jul 2026 22:34:22 +0900 Subject: [PATCH 017/281] =?UTF-8?q?MSG-112=20fix:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=A4=8C?= =?UTF-8?q?=20=ED=81=B4=EB=9E=A8=ED=95=91,=20=EB=84=A4=EB=B9=84=20?= =?UTF-8?q?=ED=82=A4=20=ED=83=80=EC=9E=85=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/routes.test.ts | 15 ++++++++++++++- apps/web/src/app/routes.ts | 3 +++ .../web/src/widgets/side-rail-nav/SideRailNav.tsx | 6 ++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/routes.test.ts b/apps/web/src/app/routes.test.ts index bded1830..d906f648 100644 --- a/apps/web/src/app/routes.test.ts +++ b/apps/web/src/app/routes.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from "vitest"; -import { ROUTES, getActiveNavKey } from "./routes"; +import { ROUTES, getActiveNavKey, isNavKey } from "./routes"; + +describe("isNavKey", () => { + it("ROUTES에 정의된 키는 true를 반환한다", () => { + for (const key of Object.keys(ROUTES)) { + expect(isNavKey(key)).toBe(true); + } + }); + + it("정의되지 않은 키는 false를 반환한다", () => { + expect(isNavKey("unknown")).toBe(false); + expect(isNavKey("")).toBe(false); + }); +}); describe("getActiveNavKey", () => { it("루트 경로는 home을 반환한다", () => { diff --git a/apps/web/src/app/routes.ts b/apps/web/src/app/routes.ts index 9d4421a2..954a70ec 100644 --- a/apps/web/src/app/routes.ts +++ b/apps/web/src/app/routes.ts @@ -9,6 +9,9 @@ export const ROUTES = { export type NavKey = keyof typeof ROUTES; +/** 외부에서 넘어온 문자열 key가 네비 키인지 좁히는 타입 가드 */ +export const isNavKey = (key: string): key is NavKey => key in ROUTES; + /** 현재 pathname이 속한 네비 섹션 키를 반환한다. 매칭되는 섹션이 없으면 undefined */ export const getActiveNavKey = (pathname: string): NavKey | undefined => { if (pathname === ROUTES.home) return "home"; diff --git a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx index 7eca4bca..5c18e3df 100644 --- a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx +++ b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx @@ -1,7 +1,7 @@ import { Compass, Home, LayoutGrid, MapPin, Upload, User } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import { SideRail, type SideRailItem } from "@fillmap/ui-web"; -import { ROUTES, getActiveNavKey, type NavKey } from "@/app/routes"; +import { ROUTES, getActiveNavKey, isNavKey, type NavKey } from "@/app/routes"; const items: (SideRailItem & { key: NavKey })[] = [ { key: "home", label: "홈", icon: }, @@ -25,7 +25,9 @@ export const SideRailNav = () => { } items={items} activeKey={getActiveNavKey(pathname)} - onSelect={(key) => navigate(ROUTES[key as NavKey])} + onSelect={(key) => { + if (isNavKey(key)) navigate(ROUTES[key]); + }} /> ); }; From 772ccd4d56adb1804c762781765ba9529dc34ccf Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Thu, 16 Jul 2026 22:41:09 +0900 Subject: [PATCH 018/281] =?UTF-8?q?MSG-112=20fix:=20env=20example=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/.env.example b/apps/web/.env.example index d1714888..ad765703 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -2,4 +2,4 @@ # 카카오맵 JavaScript 키 — https://developers.kakao.com > 내 애플리케이션 > 앱 키 # 앱 설정 > 플랫폼 > Web 사이트 도메인에 http://localhost:5173 등록 필요 -VITE_KAKAO_MAP_APP_KEY=e4bbcc653ad8a35ee001a9990f157e29 \ No newline at end of file +VITE_KAKAO_MAP_APP_KEY=your_kakao_javascript_app_key \ No newline at end of file From efa77ac441c47fd4b2ed27e64b18ff2a1b902186 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Fri, 17 Jul 2026 17:56:33 +0900 Subject: [PATCH 019/281] =?UTF-8?q?MSG-113=20feat:=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EB=B7=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/router.tsx | 12 +- apps/web/src/entities/cell/model/cell.ts | 4 + .../web/src/entities/cell/model/mock-cells.ts | 34 ++-- .../explore/model/explore-cells.test.ts | 146 +++++++++++++++++ .../features/explore/model/explore-cells.ts | 54 +++++++ .../map-home/model/cell-viewport.test.ts | 1 + apps/web/src/pages/explore/ExplorePanel.tsx | 148 ++++++++++++++++++ .../src/pages/explore/ui/ExploreCellCard.tsx | 43 +++++ apps/web/src/pages/map-home/MapHomePage.tsx | 66 ++------ apps/web/src/widgets/map-shell/MapShell.tsx | 55 +++++++ .../src/widgets/map-shell/use-map-shell.ts | 18 +++ .../src/widgets/side-rail-nav/SideRailNav.tsx | 8 +- docs/decisions/DECISIONS.md | 2 + 13 files changed, 523 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/features/explore/model/explore-cells.test.ts create mode 100644 apps/web/src/features/explore/model/explore-cells.ts create mode 100644 apps/web/src/pages/explore/ExplorePanel.tsx create mode 100644 apps/web/src/pages/explore/ui/ExploreCellCard.tsx create mode 100644 apps/web/src/widgets/map-shell/MapShell.tsx create mode 100644 apps/web/src/widgets/map-shell/use-map-shell.ts diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 445ffe7f..47be10ec 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -1,15 +1,23 @@ import { createBrowserRouter } from "react-router-dom"; import { AppLayout } from "@/app/layouts/AppLayout"; import { ROUTES } from "@/app/routes"; +import { ExplorePanel } from "@/pages/explore/ExplorePanel"; import { MapHomePage } from "@/pages/map-home/MapHomePage"; import { PlaceholderPage } from "@/pages/placeholder/PlaceholderPage"; +import { MapShell } from "@/widgets/map-shell/MapShell"; export const router = createBrowserRouter([ { element: , children: [ - { path: ROUTES.home, element: }, - { path: ROUTES.explore, element: }, + // 홈/탐색은 지속 지도 셸을 공유해 라우트 전환에도 지도가 유지된다(D1) + { + element: , + children: [ + { path: ROUTES.home, element: }, + { path: ROUTES.explore, element: }, + ], + }, { path: ROUTES.upload, element: }, { path: ROUTES.dex, element: }, { path: ROUTES.profile, element: }, diff --git a/apps/web/src/entities/cell/model/cell.ts b/apps/web/src/entities/cell/model/cell.ts index 44954361..e099a2a9 100644 --- a/apps/web/src/entities/cell/model/cell.ts +++ b/apps/web/src/entities/cell/model/cell.ts @@ -19,4 +19,8 @@ export interface Cell { center: LatLng; /** 격자에 속한 영상 수 */ videoCount: number; + /** 격자 생성 시각 (ISO 8601) — "최신순" 정렬 기준 (D3) */ + createdAt: string; + /** 대표 영상 길이(초) — 카드 길이 배지용. 없으면 배지 미표시 (S6) */ + durationSec?: number; } diff --git a/apps/web/src/entities/cell/model/mock-cells.ts b/apps/web/src/entities/cell/model/mock-cells.ts index 6bcb223b..b0401c79 100644 --- a/apps/web/src/entities/cell/model/mock-cells.ts +++ b/apps/web/src/entities/cell/model/mock-cells.ts @@ -4,22 +4,24 @@ import type { Cell } from "./cell"; * 서울 일대 mock 격자 데이터. * 실 API 연동 전까지 뷰포트 매칭·요약 집계 시연을 위한 임시 소스. * 라벨은 "지역명 + 코드" 형식(Figma 13399-1208 확인), 영상 수는 편차를 두어 배치. + * createdAt은 "최신순"(D3), durationSec은 카드 길이 배지(S5) 시연용 — + * 일부 격자는 durationSec을 생략해 배지 미표시(S6)를 검증할 수 있게 둔다. */ export const MOCK_CELLS: Cell[] = [ - { id: "A-14", label: "홍대입구 A-14", center: { lat: 37.5573, lng: 126.9245 }, videoCount: 138 }, - { id: "A-15", label: "합정 A-15", center: { lat: 37.5495, lng: 126.9137 }, videoCount: 72 }, - { id: "B-07", label: "망원 B-07", center: { lat: 37.5556, lng: 126.9016 }, videoCount: 54 }, - { id: "B-08", label: "연남 B-08", center: { lat: 37.5631, lng: 126.9256 }, videoCount: 91 }, - { id: "C-02", label: "성수 C-02", center: { lat: 37.5446, lng: 127.0559 }, videoCount: 205 }, - { id: "C-03", label: "건대입구 C-03", center: { lat: 37.5402, lng: 127.0702 }, videoCount: 47 }, - { id: "D-01", label: "이태원 D-01", center: { lat: 37.5346, lng: 126.9946 }, videoCount: 119 }, - { id: "D-02", label: "한남 D-02", center: { lat: 37.5344, lng: 127.0016 }, videoCount: 33 }, - { id: "E-05", label: "강남역 E-05", center: { lat: 37.4979, lng: 127.0276 }, videoCount: 176 }, - { id: "E-06", label: "역삼 E-06", center: { lat: 37.5006, lng: 127.0364 }, videoCount: 88 }, - { id: "F-09", label: "잠실 F-09", center: { lat: 37.5133, lng: 127.1 }, videoCount: 64 }, - { id: "F-10", label: "송파 F-10", center: { lat: 37.5145, lng: 127.106 }, videoCount: 21 }, - { id: "G-03", label: "종로 G-03", center: { lat: 37.5729, lng: 126.9793 }, videoCount: 97 }, - { id: "G-04", label: "광화문 G-04", center: { lat: 37.5716, lng: 126.9769 }, videoCount: 142 }, - { id: "H-11", label: "여의도 H-11", center: { lat: 37.5219, lng: 126.9245 }, videoCount: 58 }, - { id: "H-12", label: "노량진 H-12", center: { lat: 37.5136, lng: 126.9425 }, videoCount: 12 }, + { id: "A-14", label: "홍대입구 A-14", center: { lat: 37.5573, lng: 126.9245 }, videoCount: 138, createdAt: "2026-07-10T09:00:00.000Z", durationSec: 24 }, + { id: "A-15", label: "합정 A-15", center: { lat: 37.5495, lng: 126.9137 }, videoCount: 72, createdAt: "2026-06-28T09:00:00.000Z", durationSec: 84 }, + { id: "B-07", label: "망원 B-07", center: { lat: 37.5556, lng: 126.9016 }, videoCount: 54, createdAt: "2026-07-05T09:00:00.000Z" }, + { id: "B-08", label: "연남 B-08", center: { lat: 37.5631, lng: 126.9256 }, videoCount: 91, createdAt: "2026-07-14T09:00:00.000Z", durationSec: 132 }, + { id: "C-02", label: "성수 C-02", center: { lat: 37.5446, lng: 127.0559 }, videoCount: 205, createdAt: "2026-07-01T09:00:00.000Z", durationSec: 605 }, + { id: "C-03", label: "건대입구 C-03", center: { lat: 37.5402, lng: 127.0702 }, videoCount: 47, createdAt: "2026-06-20T09:00:00.000Z", durationSec: 47 }, + { id: "D-01", label: "이태원 D-01", center: { lat: 37.5346, lng: 126.9946 }, videoCount: 119, createdAt: "2026-07-12T09:00:00.000Z", durationSec: 210 }, + { id: "D-02", label: "한남 D-02", center: { lat: 37.5344, lng: 127.0016 }, videoCount: 33, createdAt: "2026-06-15T09:00:00.000Z" }, + { id: "E-05", label: "강남역 E-05", center: { lat: 37.4979, lng: 127.0276 }, videoCount: 176, createdAt: "2026-07-08T09:00:00.000Z", durationSec: 366 }, + { id: "E-06", label: "역삼 E-06", center: { lat: 37.5006, lng: 127.0364 }, videoCount: 88, createdAt: "2026-06-30T09:00:00.000Z", durationSec: 59 }, + { id: "F-09", label: "잠실 F-09", center: { lat: 37.5133, lng: 127.1 }, videoCount: 64, createdAt: "2026-07-11T09:00:00.000Z", durationSec: 148 }, + { id: "F-10", label: "송파 F-10", center: { lat: 37.5145, lng: 127.106 }, videoCount: 21, createdAt: "2026-06-25T09:00:00.000Z" }, + { id: "G-03", label: "종로 G-03", center: { lat: 37.5729, lng: 126.9793 }, videoCount: 97, createdAt: "2026-07-13T09:00:00.000Z", durationSec: 302 }, + { id: "G-04", label: "광화문 G-04", center: { lat: 37.5716, lng: 126.9769 }, videoCount: 142, createdAt: "2026-07-03T09:00:00.000Z", durationSec: 75 }, + { id: "H-11", label: "여의도 H-11", center: { lat: 37.5219, lng: 126.9245 }, videoCount: 58, createdAt: "2026-07-06T09:00:00.000Z", durationSec: 41 }, + { id: "H-12", label: "노량진 H-12", center: { lat: 37.5136, lng: 126.9425 }, videoCount: 12, createdAt: "2026-06-18T09:00:00.000Z" }, ]; diff --git a/apps/web/src/features/explore/model/explore-cells.test.ts b/apps/web/src/features/explore/model/explore-cells.test.ts new file mode 100644 index 00000000..168f7efb --- /dev/null +++ b/apps/web/src/features/explore/model/explore-cells.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { Cell } from "@/entities/cell"; +import { + formatDuration, + searchCells, + selectExploreCells, + sortCells, +} from "./explore-cells"; + +const cell = ( + id: string, + label: string, + videoCount: number, + createdAt: string, +): Cell => ({ + id, + label, + center: { lat: 0, lng: 0 }, + videoCount, + createdAt, +}); + +describe("searchCells (L1)", () => { + const cells = [ + cell("A", "홍대입구 A-14", 10, "2026-01-01T00:00:00.000Z"), + cell("B", "합정 A-15", 20, "2026-01-02T00:00:00.000Z"), + cell("C", "성수 C-02", 30, "2026-01-03T00:00:00.000Z"), + ]; + + it("검색어가 label의 부분 문자열인 격자만 반환한다", () => { + expect(searchCells(cells, "홍대").map((c) => c.id)).toEqual(["A"]); + }); + + it("대소문자를 무시하고 매칭한다", () => { + expect(searchCells(cells, "c-02").map((c) => c.id)).toEqual(["C"]); + expect(searchCells(cells, "a-1").map((c) => c.id)).toEqual(["A", "B"]); + }); + + it("빈 검색어는 입력 목록을 그대로 반환한다", () => { + expect(searchCells(cells, "")).toEqual(cells); + }); + + it("공백만 있는 검색어는 입력 목록을 그대로 반환한다", () => { + expect(searchCells(cells, " ")).toEqual(cells); + }); + + it("매칭이 없으면 빈 배열을 반환한다", () => { + expect(searchCells(cells, "존재하지않음")).toEqual([]); + }); +}); + +describe("sortCells (L2)", () => { + it("popular는 videoCount 내림차순으로 정렬한다", () => { + const cells = [ + cell("a", "a", 10, "2026-01-01T00:00:00.000Z"), + cell("b", "b", 50, "2026-01-01T00:00:00.000Z"), + cell("c", "c", 30, "2026-01-01T00:00:00.000Z"), + ]; + + expect(sortCells(cells, "popular").map((c) => c.id)).toEqual([ + "b", + "c", + "a", + ]); + }); + + it("popular는 videoCount 동률 시 id 오름차순으로 안정 정렬한다", () => { + const cells = [ + cell("z", "z", 40, "2026-01-01T00:00:00.000Z"), + cell("m", "m", 40, "2026-01-01T00:00:00.000Z"), + cell("a", "a", 40, "2026-01-01T00:00:00.000Z"), + ]; + + expect(sortCells(cells, "popular").map((c) => c.id)).toEqual([ + "a", + "m", + "z", + ]); + }); + + it("recent는 createdAt 내림차순(최신 우선)으로 정렬한다", () => { + const cells = [ + cell("old", "old", 99, "2026-01-01T00:00:00.000Z"), + cell("new", "new", 1, "2026-03-01T00:00:00.000Z"), + cell("mid", "mid", 50, "2026-02-01T00:00:00.000Z"), + ]; + + expect(sortCells(cells, "recent").map((c) => c.id)).toEqual([ + "new", + "mid", + "old", + ]); + }); + + it("원본 배열을 변형하지 않는다", () => { + const cells = [ + cell("a", "a", 1, "2026-01-01T00:00:00.000Z"), + cell("b", "b", 2, "2026-01-02T00:00:00.000Z"), + ]; + const snapshot = cells.map((c) => c.id); + + sortCells(cells, "popular"); + sortCells(cells, "recent"); + + expect(cells.map((c) => c.id)).toEqual(snapshot); + }); +}); + +describe("formatDuration (L3)", () => { + it("초 값을 m:ss로 포맷한다", () => { + expect(formatDuration(24)).toBe("0:24"); + expect(formatDuration(84)).toBe("1:24"); + expect(formatDuration(605)).toBe("10:05"); + }); + + it("undefined이면 null을 반환한다(배지 미표시 신호)", () => { + expect(formatDuration(undefined)).toBeNull(); + }); +}); + +describe("selectExploreCells 검색+정렬 파이프라인 (L4)", () => { + const cells = [ + cell("A", "홍대입구 A-14", 10, "2026-01-03T00:00:00.000Z"), + cell("B", "합정 A-15", 50, "2026-01-01T00:00:00.000Z"), + cell("C", "성수 C-02", 30, "2026-01-02T00:00:00.000Z"), + cell("D", "홍대 A-16", 40, "2026-01-04T00:00:00.000Z"), + ]; + + it("정렬 상태를 바꿔도 동일 검색어의 결과 집합(원소)은 동일하다", () => { + const query = "홍대"; + const popular = selectExploreCells(cells, { query, order: "popular" }); + const recent = selectExploreCells(cells, { query, order: "recent" }); + + const ids = (list: Cell[]) => [...list.map((c) => c.id)].sort(); + expect(ids(popular)).toEqual(ids(recent)); + }); + + it("정렬은 순서만 결정한다 — 같은 집합을 다른 순서로 반환한다", () => { + const query = ""; + const popular = selectExploreCells(cells, { query, order: "popular" }); + const recent = selectExploreCells(cells, { query, order: "recent" }); + + expect(popular.map((c) => c.id)).toEqual(["B", "D", "C", "A"]); + expect(recent.map((c) => c.id)).toEqual(["D", "A", "C", "B"]); + }); +}); diff --git a/apps/web/src/features/explore/model/explore-cells.ts b/apps/web/src/features/explore/model/explore-cells.ts new file mode 100644 index 00000000..5d04e99e --- /dev/null +++ b/apps/web/src/features/explore/model/explore-cells.ts @@ -0,0 +1,54 @@ +import type { Cell } from "@/entities/cell"; + +/** 탐색 패널 정렬 순서 — 인기순(videoCount) / 최신순(createdAt) */ +export type SortOrder = "popular" | "recent"; + +/** 탐색 파생 셀렉터 입력 */ +export interface ExploreQuery { + query: string; + order: SortOrder; +} + +/** + * 검색어를 label(동네명+코드)의 부분 문자열로 매칭한다(대소문자 무시). [L1] + * 빈/공백 검색어는 입력 목록을 그대로 반환한다 — 검색은 결과 "집합"만 결정한다. + * 순수 함수 — 지도 SDK/플랫폼에 의존하지 않는다(RN 재사용 대상). + */ +export const searchCells = (cells: Cell[], query: string): Cell[] => { + const q = query.trim().toLowerCase(); + if (!q) return cells; + return cells.filter((cell) => cell.label.toLowerCase().includes(q)); +}; + +/** + * 정렬한다 — 검색은 "순서"만 결정한다. 원본 배열은 변형하지 않는다. [L2] + * - `"popular"`: videoCount 내림차순, 동률 시 id 오름차순 안정 정렬 + * - `"recent"`: createdAt 내림차순(최신 우선), 동률 시 id 오름차순 안정 정렬 + */ +export const sortCells = (cells: Cell[], order: SortOrder): Cell[] => + [...cells].sort((a, b) => + order === "popular" + ? b.videoCount - a.videoCount || a.id.localeCompare(b.id) + : b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id), + ); + +/** + * 영상 길이(초)를 `m:ss`로 포맷한다(24→"0:24", 84→"1:24", 605→"10:05"). [L3] + * 값이 `undefined`이면 null을 반환한다 — 배지 미표시 신호(S6). + */ +export const formatDuration = (sec?: number): string | null => { + if (sec === undefined) return null; + const minutes = Math.floor(sec / 60); + const seconds = sec % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +}; + +/** + * 검색 → 정렬 파이프라인. [L4] + * 정렬 상태를 바꿔도 동일 검색어의 결과 집합(원소)은 동일하다 — + * 검색은 집합만, 정렬은 순서만 결정한다. + */ +export const selectExploreCells = ( + cells: Cell[], + { query, order }: ExploreQuery, +): Cell[] => sortCells(searchCells(cells, query), order); diff --git a/apps/web/src/features/map-home/model/cell-viewport.test.ts b/apps/web/src/features/map-home/model/cell-viewport.test.ts index 39d12b3f..afcff400 100644 --- a/apps/web/src/features/map-home/model/cell-viewport.test.ts +++ b/apps/web/src/features/map-home/model/cell-viewport.test.ts @@ -11,6 +11,7 @@ const cell = (id: string, lat: number, lng: number, videoCount: number): Cell => label: id, center: { lat, lng }, videoCount, + createdAt: "2026-01-01T00:00:00.000Z", }); // 서울 도심을 감싸는 예시 bounds diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx new file mode 100644 index 00000000..8135627a --- /dev/null +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { Chip, SearchBar } from "@fillmap/ui-web"; +import type { Bounds, Cell, LatLng } from "@/entities/cell"; +import { + selectExploreCells, + type SortOrder, +} from "@/features/explore/model/explore-cells"; +import { + filterCellsInBounds, + summarizeCells, +} from "@/features/map-home/model/cell-viewport"; +import { useCellsQuery } from "@/features/map-home/model/use-cells-query"; +import { useViewportStore } from "@/features/map-home/model/viewport-store"; +import { useMapShell } from "@/widgets/map-shell/use-map-shell"; +import { ExploreCellCard } from "./ui/ExploreCellCard"; + +/** S4 지역명 — 뷰포트→행정구역명 변환은 범위 밖, 고정 목값 표시(D4) */ +const REGION_LABEL = "서울 마포구 격자"; + +/** + * 탐색 패널(`/explore`) — 지속 셸(MapShell)이 렌더한 지도 위에 얹는 388px 오버레이(S1). + * 검색창(S2)+정렬 칩(S3)+뷰포트 요약 헤더(S4)+2열 카드 그리드(S5)+빈 상태(S9)로 구성된다. + * 검색·정렬 상태는 로컬로 관리하고, 목록 파생은 순수 셀렉터(selectExploreCells)에 위임한다. + */ +export const ExplorePanel = () => { + const { moveTo } = useMapShell(); + const bounds = useViewportStore((s) => s.bounds); + const { data, isLoading, isError, refetch } = useCellsQuery(); + + const [query, setQuery] = useState(""); + const [order, setOrder] = useState("popular"); + + return ( + + ); +}; + +interface ExploreBodyProps { + bounds: Bounds | null; + cells: Cell[]; + isLoading: boolean; + isError: boolean; + onRetry: () => void; + query: string; + order: SortOrder; + onCellSelect: (center: LatLng) => void; +} + +/** 요약 헤더 + 카드 그리드 / 로딩 · 에러 · 빈 상태 분기 */ +const ExploreBody = ({ + bounds, + cells, + isLoading, + isError, + onRetry, + query, + order, + onCellSelect, +}: ExploreBodyProps) => { + if (isError) { + return ( +
+

+ 정보를 불러오지 못했어요 +

+ +
+ ); + } + + if (isLoading || !bounds) { + return ( +

+ 이 지역 정보를 불러오는 중이에요… +

+ ); + } + + const visibleCells = filterCellsInBounds(cells, bounds); + const { cellCount } = summarizeCells(visibleCells); + const displayCells = selectExploreCells(visibleCells, { query, order }); + + return ( + <> +
+ {REGION_LABEL} + {cellCount}개 +
+ +
+ {displayCells.length === 0 ? ( +

+ {query.trim() + ? "검색 결과가 없어요. 다른 이름으로 검색해 보세요." + : "이 지역에는 아직 격자가 없어요. 지도를 움직여 다른 지역을 둘러보세요."} +

+ ) : ( +
+ {displayCells.map((cell) => ( + + ))} +
+ )} +
+ + ); +}; diff --git a/apps/web/src/pages/explore/ui/ExploreCellCard.tsx b/apps/web/src/pages/explore/ui/ExploreCellCard.tsx new file mode 100644 index 00000000..d06839ad --- /dev/null +++ b/apps/web/src/pages/explore/ui/ExploreCellCard.tsx @@ -0,0 +1,43 @@ +import { Play } from "lucide-react"; +import type { Cell, LatLng } from "@/entities/cell"; +import { formatDuration } from "@/features/explore/model/explore-cells"; + +interface ExploreCellCardProps { + cell: Cell; + /** 카드 클릭 시 지도를 해당 격자 중심으로 이동(S7) */ + onSelect: (center: LatLng) => void; +} + +/** + * 탐색 격자 카드 — 썸네일(공용 placeholder)+재생 아이콘 오버레이+영상 길이 배지, + * 동네명+코드(S5), "N개 영상"을 표시한다. durationSec이 없으면 배지 미표시(S6). + * 2열 그리드 셀로 배치되며 너비는 부모 그리드가 결정한다. + */ +export const ExploreCellCard = ({ cell, onSelect }: ExploreCellCardProps) => { + const duration = formatDuration(cell.durationSec); + + return ( + + ); +}; diff --git a/apps/web/src/pages/map-home/MapHomePage.tsx b/apps/web/src/pages/map-home/MapHomePage.tsx index 24d5d88b..7e3d9bf4 100644 --- a/apps/web/src/pages/map-home/MapHomePage.tsx +++ b/apps/web/src/pages/map-home/MapHomePage.tsx @@ -1,67 +1,35 @@ -import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { SearchBar } from "@fillmap/ui-web"; import { ROUTES } from "@/app/routes"; -import type { LatLng } from "@/entities/cell"; -import { useViewportStore } from "@/features/map-home/model/viewport-store"; -import { SEOUL_CITY_HALL, getCurrentPosition } from "@/shared/geolocation"; -import { MapCanvas, type MapCanvasHandle } from "./ui/MapCanvas"; +import { useMapShell } from "@/widgets/map-shell/use-map-shell"; import { CellSummaryPanel } from "./ui/CellSummaryPanel"; import { MapControls } from "./ui/MapControls"; /** - * 지도 홈 페이지(`/`) — 카카오맵 배경 + 검색바·요약 패널·우하단 컨트롤 조립(얇은 뷰). - * 상태·데이터 로직은 features/entities로 내리고, 여기서는 배치와 콜백 연결만 담당한다. + * 지도 홈 오버레이(`/`) — 지속 셸(MapShell)이 렌더한 지도 위에 얹는 홈 전용 오버레이. + * 검색바·요약 패널·우하단 컨트롤 조립(얇은 뷰). 지도 명령은 셸 API(useMapShell)로 받는다. */ export const MapHomePage = () => { const navigate = useNavigate(); - const setViewport = useViewportStore((s) => s.setViewport); - const mapRef = useRef(null); - const [initialCenter, setInitialCenter] = useState(SEOUL_CITY_HALL); - - // 진입 시 현재 위치로 초기 중심 설정 (권한 거부/실패 시 서울 시청 폴백, S2) - useEffect(() => { - let active = true; - getCurrentPosition().then((coords) => { - if (active) setInitialCenter(coords); - }); - return () => { - active = false; - }; - }, []); - - // 현재 위치 재이동 (폴백 일관성 — L6 어댑터 재사용, S9) - const handleLocate = () => { - getCurrentPosition().then((coords) => mapRef.current?.moveTo(coords)); - }; + const { moveTo, zoomIn, zoomOut, locate } = useMapShell(); return ( -
-
- +
+ + navigate(ROUTES.explore)} + onCellSelect={moveTo} />
-
-
- - navigate(ROUTES.explore)} - onCellSelect={(center) => mapRef.current?.moveTo(center)} - /> -
- -
- navigate(ROUTES.upload)} - onLocate={handleLocate} - onZoomIn={() => mapRef.current?.zoomIn()} - onZoomOut={() => mapRef.current?.zoomOut()} - /> -
+
+ navigate(ROUTES.upload)} + onLocate={locate} + onZoomIn={zoomIn} + onZoomOut={zoomOut} + />
); diff --git a/apps/web/src/widgets/map-shell/MapShell.tsx b/apps/web/src/widgets/map-shell/MapShell.tsx new file mode 100644 index 00000000..a4a6820e --- /dev/null +++ b/apps/web/src/widgets/map-shell/MapShell.tsx @@ -0,0 +1,55 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Outlet } from "react-router-dom"; +import type { LatLng } from "@/entities/cell"; +import { useViewportStore } from "@/features/map-home/model/viewport-store"; +import { MapCanvas, type MapCanvasHandle } from "@/pages/map-home/ui/MapCanvas"; +import { SEOUL_CITY_HALL, getCurrentPosition } from "@/shared/geolocation"; +import type { MapShellContext } from "./use-map-shell"; + +/** + * 지속 지도 셸 — 홈(`/`)과 탐색(`/explore`)이 이 셸을 공유하므로 지도 인스턴스와 + * 뷰포트 상태가 라우트 전환에도 유지된다(D1). 경로별 오버레이는 Outlet으로 스위칭한다. + * 지도 SDK import는 MapCanvas 경계 안에만 두고, 셸은 배치와 명령 주입만 담당한다. + */ +export const MapShell = () => { + const setViewport = useViewportStore((s) => s.setViewport); + const mapRef = useRef(null); + const [initialCenter, setInitialCenter] = useState(SEOUL_CITY_HALL); + + // 진입 시 현재 위치로 초기 중심 설정 (권한 거부/실패 시 서울 시청 폴백) + useEffect(() => { + let active = true; + getCurrentPosition().then((coords) => { + if (active) setInitialCenter(coords); + }); + return () => { + active = false; + }; + }, []); + + const context = useMemo( + () => ({ + moveTo: (coords) => mapRef.current?.moveTo(coords), + zoomIn: () => mapRef.current?.zoomIn(), + zoomOut: () => mapRef.current?.zoomOut(), + locate: () => { + getCurrentPosition().then((coords) => mapRef.current?.moveTo(coords)); + }, + }), + [], + ); + + return ( +
+
+ +
+ + +
+ ); +}; diff --git a/apps/web/src/widgets/map-shell/use-map-shell.ts b/apps/web/src/widgets/map-shell/use-map-shell.ts new file mode 100644 index 00000000..d05dc97d --- /dev/null +++ b/apps/web/src/widgets/map-shell/use-map-shell.ts @@ -0,0 +1,18 @@ +import { useOutletContext } from "react-router-dom"; +import type { LatLng } from "@/entities/cell"; + +/** + * 지도 명령 API — 지속 셸(MapShell)이 오버레이(홈/탐색)에 주입한다. + * 지도 인스턴스 제어를 오버레이에 노출하지 않고 명령만 공개한다. + */ +export interface MapShellContext { + /** 지정 좌표로 지도 이동 */ + moveTo: (coords: LatLng) => void; + zoomIn: () => void; + zoomOut: () => void; + /** 현재 위치(폴백 시 서울 시청)로 재이동 */ + locate: () => void; +} + +/** 오버레이(Outlet 자식)에서 지도 명령 API를 받는 뷰-레이어 훅 */ +export const useMapShell = () => useOutletContext(); diff --git a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx index 5c18e3df..341b60dc 100644 --- a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx +++ b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx @@ -26,7 +26,13 @@ export const SideRailNav = () => { items={items} activeKey={getActiveNavKey(pathname)} onSelect={(key) => { - if (isNavKey(key)) navigate(ROUTES[key]); + if (!isNavKey(key)) return; + // 탐색을 다시 누르면 패널을 닫는다 — 홈으로 복귀(S10) + if (key === "explore" && getActiveNavKey(pathname) === "explore") { + navigate(ROUTES.home); + return; + } + navigate(ROUTES[key]); }} /> ); diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index 34c75cd4..bc2a5ea2 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -10,3 +10,5 @@ | 2026-07-16 | MSG-112 | 결정: `shared/geolocation.ts`가 `entities/cell`의 `LatLng`를 import하지 않고 구조 호환 `GeoCoords`를 로컬 정의 | FSD 최하위 layer인 shared가 상위 entities에 의존하면 layer 규칙 위반. 두 타입은 `{lat,lng}`로 구조 호환이라 대입 가능 — 결합 없이 재사용성 유지 | | 2026-07-16 | MSG-112 | 결정: 카카오맵 SDK 로드 실패 재시도(S3)를 `attempt` state 키로 하위 뷰 remount하여 `useKakaoLoader`를 재실행 | 로더는 멱등·캐시라 동일 위치 재호출로는 재로딩 안 됨. remount가 SDK 경계 안에서 재시도를 트리거하는 가장 단순한 방법 | | 2026-07-16 | MSG-112 | 발견: 카카오맵 SDK가 `http://localhost` 출처의 요청을 503으로 거부(콘솔 도메인 등록과 별개) — dev에서 지도를 보려면 https 서빙 필요 | 검증 중 네트워크 계측으로 확인: 같은 키로 https·무Referer 요청은 200, `Referer: http://localhost:5173` 요청은 503. SDK가 프로토콜 상대 URL을 써서 http 페이지에선 http로 요청됨. dev https화(예: vite basic-ssl)는 별도 티켓 권장 | +| 2026-07-17 | MSG-113 | 결정: 지도를 `MapHomePage` 소유에서 `MapShell`(라우트 상위 지속 셸)로 이관, `/`·`/explore`는 그 위에 오버레이만 스위칭 | 티켓이 "탐색 패널은 지도 위 오버레이, 지도는 뒤에서 계속 보임"을 요구했는데 기존 라우터는 `/explore` 전환 시 `MapHomePage`가 언마운트돼 지도가 사라짐. 지도 소유권을 셸로 올리면 홈↔탐색 왕복에도 지도 중심·줌이 유지됨(검증 단계에서 회귀 없음 확인) | +| 2026-07-17 | MSG-113 | 결정: 카드 클릭 시 지도 "이동"만 구현, 지도 위 격자 "강조" 렌더링은 범위 밖으로 분리 | 티켓 문구는 강조를 "기존 로직 재사용"이라 했으나 실제 `MapCanvas`엔 강조 렌더링 자체가 없었음(이동만 존재) — 없는 것을 재사용할 수 없어 신규 구현 여부를 사용자에게 확인, 범위 확대 대신 후속 티켓으로 분리하기로 결정 | From 1fbc8436ae74168caec7a13efea80b6a03a45547 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Fri, 17 Jul 2026 20:37:31 +0900 Subject: [PATCH 020/281] =?UTF-8?q?MSG-113=20fix:=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=8B=9C=20=ED=8F=AD=20=EB=B0=80=EB=A6=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ExplorePanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index 8135627a..d59de079 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -124,7 +124,7 @@ const ExploreBody = ({ {cellCount}개
-
+
{displayCells.length === 0 ? (

{query.trim() From c8c2b3499ebcdfb7cacadaf74dc2a22ae54fe488 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Fri, 17 Jul 2026 20:45:42 +0900 Subject: [PATCH 021/281] =?UTF-8?q?MSG-113=20fix:=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20UI=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20=EC=B9=B4=EB=93=9C=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=B0=80=EB=A6=BC,?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=B9=A9=20=ED=8F=AD=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ExplorePanel.tsx | 11 ++++----- apps/web/src/pages/explore/ui/SortChip.tsx | 25 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/pages/explore/ui/SortChip.tsx diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index d59de079..4ffeba04 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Chip, SearchBar } from "@fillmap/ui-web"; +import { SearchBar } from "@fillmap/ui-web"; import type { Bounds, Cell, LatLng } from "@/entities/cell"; import { selectExploreCells, @@ -13,6 +13,7 @@ import { useCellsQuery } from "@/features/map-home/model/use-cells-query"; import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { useMapShell } from "@/widgets/map-shell/use-map-shell"; import { ExploreCellCard } from "./ui/ExploreCellCard"; +import { SortChip } from "./ui/SortChip"; /** S4 지역명 — 뷰포트→행정구역명 변환은 범위 밖, 고정 목값 표시(D4) */ const REGION_LABEL = "서울 마포구 격자"; @@ -39,13 +40,13 @@ export const ExplorePanel = () => { onChange={(e) => setQuery(e.target.value)} />

- setOrder("popular")} /> - setOrder("recent")} /> diff --git a/apps/web/src/pages/explore/ui/SortChip.tsx b/apps/web/src/pages/explore/ui/SortChip.tsx new file mode 100644 index 00000000..1aeff39c --- /dev/null +++ b/apps/web/src/pages/explore/ui/SortChip.tsx @@ -0,0 +1,25 @@ +interface SortChipProps { + label: string; + active: boolean; + onClick: () => void; +} + +/** + * 정렬 토글 칩 — Figma "격자 썸네일 뷰"(node 13399:1262)의 chip/chip-active 버튼. + * 공용 ui-web `Chip`(FeelMap Chip, 13428:693)은 active 시 체크 아이콘이 붙어 폭이 변하므로 + * 이 화면의 "폭 고정, 색만 전환" 디자인과 맞지 않아 로컬로 둔다. + */ +export const SortChip = ({ label, active, onClick }: SortChipProps) => ( + +); From 1dcf615e6772b37ab8eea3c6b9fd88e17a0e030b Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Fri, 17 Jul 2026 20:56:21 +0900 Subject: [PATCH 022/281] =?UTF-8?q?MSG-113=20chore:=20Tailwind=20=EC=9E=84?= =?UTF-8?q?=EC=9D=98=EA=B0=92=20=EB=8C=80=EC=8B=A0=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=BC=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/page-implementation/SKILL.md | 1 + apps/web/src/pages/explore/ui/SortChip.tsx | 4 ++-- docs/DESIGN_SYSTEM.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.claude/skills/page-implementation/SKILL.md b/.claude/skills/page-implementation/SKILL.md index 8075e748..740fbc0c 100644 --- a/.claude/skills/page-implementation/SKILL.md +++ b/.claude/skills/page-implementation/SKILL.md @@ -50,6 +50,7 @@ description: "FillMap 웹 페이지 구현 컨벤션 — 디렉토리 구조(FSD - 색상·크기·타이포는 토큰 클래스만: `bg-primary`, `gap-md`, `text-fm-body` 등. hex/px 리터럴과 Tailwind 임의값(`bg-[#fff]`) 금지 - 시맨틱 토큰(`primary`, `background`) 우선, 원시 토큰(`blue-500`)은 시맨틱으로 표현 불가할 때만 +- 컴포넌트 고유 치수(`min-w-[60px]` 등)로 임의값이 불가피할 때도, Tailwind 기본 스케일(4px 단위)로 정확히 나오는 값이면 스케일 클래스를 쓴다 — `min-w-[40px]`이 아니라 `min-w-10`. 스케일에 없는 값에서만 임의값 유지 - 사용 가능한 토큰 클래스 목록: `docs/DESIGN_SYSTEM.md` 참조 ## RN 대비 경계 규칙 diff --git a/apps/web/src/pages/explore/ui/SortChip.tsx b/apps/web/src/pages/explore/ui/SortChip.tsx index 1aeff39c..82dfffe3 100644 --- a/apps/web/src/pages/explore/ui/SortChip.tsx +++ b/apps/web/src/pages/explore/ui/SortChip.tsx @@ -16,8 +16,8 @@ export const SortChip = ({ label, active, onClick }: SortChipProps) => ( onClick={onClick} className={ active - ? "min-w-[40px] rounded-full bg-primary px-[12px] py-[6px] text-fm-body-strong text-primary-foreground" - : "min-w-[40px] rounded-full border border-border bg-surface-soft px-[12px] py-[6px] text-fm-body-strong text-foreground-body" + ? "min-w-10 rounded-full bg-primary px-3 py-1.5 text-fm-body-strong text-primary-foreground" + : "min-w-10 rounded-full border border-border bg-surface-soft px-3 py-1.5 text-fm-body-strong text-foreground-body" } > {label} diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index b59be759..e931d0cc 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -23,7 +23,7 @@ apps/web (tailwind.config.ts) → @fillmap/tailwind-preset → @fillmap/desi ## 반드시 지킬 규칙 (6개조) -1. **색상·크기·타이포 값의 유일한 출처는 `design-tokens`.** 컴포넌트/앱 코드에 hex, px 리터럴 금지. Tailwind 임의값(`bg-[#fff]`) 금지 — 단, 컴포넌트 고유 치수(`min-w-[60px]` 등)는 variant 정의 안에서만 허용. +1. **색상·크기·타이포 값의 유일한 출처는 `design-tokens`.** 컴포넌트/앱 코드에 hex, px 리터럴 금지. Tailwind 임의값(`bg-[#fff]`) 금지 — 단, 컴포넌트 고유 치수(`min-w-[60px]` 등)는 variant 정의 안에서만 허용. 이 예외 안에서도 Tailwind 기본 스케일(4px 단위)로 정확히 표현되는 값은 임의값 대신 스케일 클래스를 쓴다 — 예: `min-w-[40px]` 대신 `min-w-10`, `p-[16px]` 대신 `p-4`. 스케일에 없는 값(예: 6px, 14px)에서만 임의값이 남는다. 2. **원시 토큰(`blue-500` 등)보다 시맨틱 토큰(`primary`, `background` 등) 우선 사용.** 시맨틱으로 표현 안 되는 경우에만 원시 토큰 직접 사용. 3. **`ui-web`(추후 `ui-native`)에는 도메인 무관 컴포넌트만.** API 호출·비즈니스 로직은 각 앱의 `features/`로. 4. **variant API는 `design-tokens/src/variants.ts`의 공용 타입에서 시작.** 웹/앱 컴포넌트가 같은 union 타입을 import한다. 한쪽에만 variant를 추가하지 않는다. From 314b4e8bd0b4de631da1e6dc2efdef3be54cbe29 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Fri, 17 Jul 2026 21:44:04 +0900 Subject: [PATCH 023/281] =?UTF-8?q?MSG-113=20fix:=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=ED=95=84=ED=84=B0=C2=B7=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=84=EC=82=B0=EC=97=90=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EC=9D=B4=EC=A0=9C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ExplorePanel.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index 4ffeba04..37a98181 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { SearchBar } from "@fillmap/ui-web"; import type { Bounds, Cell, LatLng } from "@/entities/cell"; import { @@ -89,6 +89,18 @@ const ExploreBody = ({ order, onCellSelect, }: ExploreBodyProps) => { + // early return(isError·isLoading) 아래에 두면 렌더마다 훅 호출 여부가 달라져 + // Rules of Hooks를 어기므로, 분기 위에서 무조건 호출하고 null 처리는 내부에서 한다. + const visibleCells = useMemo( + () => (bounds ? filterCellsInBounds(cells, bounds) : []), + [cells, bounds], + ); + const { cellCount } = useMemo(() => summarizeCells(visibleCells), [visibleCells]); + const displayCells = useMemo( + () => selectExploreCells(visibleCells, { query, order }), + [visibleCells, query, order], + ); + if (isError) { return (
@@ -114,10 +126,6 @@ const ExploreBody = ({ ); } - const visibleCells = filterCellsInBounds(cells, bounds); - const { cellCount } = summarizeCells(visibleCells); - const displayCells = selectExploreCells(visibleCells, { query, order }); - return ( <>
From 7fd606fa21cee5b0553d491d8b5a9a05eba4df9d Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Fri, 17 Jul 2026 21:51:23 +0900 Subject: [PATCH 024/281] =?UTF-8?q?MSG-113=20fix:=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=9D=BC=EB=B2=A8=EC=97=90=20title=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ui/ExploreCellCard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/explore/ui/ExploreCellCard.tsx b/apps/web/src/pages/explore/ui/ExploreCellCard.tsx index d06839ad..d5f8f4d8 100644 --- a/apps/web/src/pages/explore/ui/ExploreCellCard.tsx +++ b/apps/web/src/pages/explore/ui/ExploreCellCard.tsx @@ -32,7 +32,10 @@ export const ExploreCellCard = ({ cell, onSelect }: ExploreCellCardProps) => { )} - + {cell.label} From 929c5a1374cfe63e569b80102826a9a74903cb63 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Fri, 17 Jul 2026 23:17:51 +0900 Subject: [PATCH 025/281] =?UTF-8?q?MSG-114=20feat:=20=EA=B2=80=EC=83=89/?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=ED=95=84=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/entities/cell/model/cell.ts | 2 + .../web/src/entities/cell/model/mock-cells.ts | 33 ++-- apps/web/src/entities/region/index.ts | 2 + .../src/entities/region/model/regions.test.ts | 39 +++++ apps/web/src/entities/region/model/regions.ts | 33 ++++ .../explore/model/explore-cells.test.ts | 59 +++++++ .../features/explore/model/explore-cells.ts | 27 ++- .../model/explore-filter-store.test.ts | 73 ++++++++ .../explore/model/explore-filter-store.ts | 60 +++++++ .../explore/model/recent-history.test.ts | 76 +++++++++ .../features/explore/model/recent-history.ts | 36 ++++ .../map-home/model/cell-viewport.test.ts | 1 + apps/web/src/pages/explore/ExplorePanel.tsx | 86 ++++++---- .../src/pages/explore/ui/RecentSearchRow.tsx | 39 +++++ apps/web/src/pages/explore/ui/RegionRow.tsx | 30 ++++ apps/web/src/pages/explore/ui/SearchPanel.tsx | 160 ++++++++++++++++++ docs/decisions/DECISIONS.md | 2 + 17 files changed, 703 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/entities/region/index.ts create mode 100644 apps/web/src/entities/region/model/regions.test.ts create mode 100644 apps/web/src/entities/region/model/regions.ts create mode 100644 apps/web/src/features/explore/model/explore-filter-store.test.ts create mode 100644 apps/web/src/features/explore/model/explore-filter-store.ts create mode 100644 apps/web/src/features/explore/model/recent-history.test.ts create mode 100644 apps/web/src/features/explore/model/recent-history.ts create mode 100644 apps/web/src/pages/explore/ui/RecentSearchRow.tsx create mode 100644 apps/web/src/pages/explore/ui/RegionRow.tsx create mode 100644 apps/web/src/pages/explore/ui/SearchPanel.tsx diff --git a/apps/web/src/entities/cell/model/cell.ts b/apps/web/src/entities/cell/model/cell.ts index e099a2a9..ba757523 100644 --- a/apps/web/src/entities/cell/model/cell.ts +++ b/apps/web/src/entities/cell/model/cell.ts @@ -15,6 +15,8 @@ export interface Cell { id: string; /** 지역명 + 코드 (예: "홍대입구 A-14") */ label: string; + /** 행정구(區) 이름 (예: "마포구") — 지역 필터 매칭 키 (MSG-114 D1) */ + district: string; /** 격자 중심 좌표 */ center: LatLng; /** 격자에 속한 영상 수 */ diff --git a/apps/web/src/entities/cell/model/mock-cells.ts b/apps/web/src/entities/cell/model/mock-cells.ts index b0401c79..09cb915a 100644 --- a/apps/web/src/entities/cell/model/mock-cells.ts +++ b/apps/web/src/entities/cell/model/mock-cells.ts @@ -6,22 +6,23 @@ import type { Cell } from "./cell"; * 라벨은 "지역명 + 코드" 형식(Figma 13399-1208 확인), 영상 수는 편차를 두어 배치. * createdAt은 "최신순"(D3), durationSec은 카드 길이 배지(S5) 시연용 — * 일부 격자는 durationSec을 생략해 배지 미표시(S6)를 검증할 수 있게 둔다. + * district는 지역 필터(MSG-114 D1) 매칭 키 — 값은 전체 지역 목데이터(entities/region)의 구 이름과 일치시킨다. */ export const MOCK_CELLS: Cell[] = [ - { id: "A-14", label: "홍대입구 A-14", center: { lat: 37.5573, lng: 126.9245 }, videoCount: 138, createdAt: "2026-07-10T09:00:00.000Z", durationSec: 24 }, - { id: "A-15", label: "합정 A-15", center: { lat: 37.5495, lng: 126.9137 }, videoCount: 72, createdAt: "2026-06-28T09:00:00.000Z", durationSec: 84 }, - { id: "B-07", label: "망원 B-07", center: { lat: 37.5556, lng: 126.9016 }, videoCount: 54, createdAt: "2026-07-05T09:00:00.000Z" }, - { id: "B-08", label: "연남 B-08", center: { lat: 37.5631, lng: 126.9256 }, videoCount: 91, createdAt: "2026-07-14T09:00:00.000Z", durationSec: 132 }, - { id: "C-02", label: "성수 C-02", center: { lat: 37.5446, lng: 127.0559 }, videoCount: 205, createdAt: "2026-07-01T09:00:00.000Z", durationSec: 605 }, - { id: "C-03", label: "건대입구 C-03", center: { lat: 37.5402, lng: 127.0702 }, videoCount: 47, createdAt: "2026-06-20T09:00:00.000Z", durationSec: 47 }, - { id: "D-01", label: "이태원 D-01", center: { lat: 37.5346, lng: 126.9946 }, videoCount: 119, createdAt: "2026-07-12T09:00:00.000Z", durationSec: 210 }, - { id: "D-02", label: "한남 D-02", center: { lat: 37.5344, lng: 127.0016 }, videoCount: 33, createdAt: "2026-06-15T09:00:00.000Z" }, - { id: "E-05", label: "강남역 E-05", center: { lat: 37.4979, lng: 127.0276 }, videoCount: 176, createdAt: "2026-07-08T09:00:00.000Z", durationSec: 366 }, - { id: "E-06", label: "역삼 E-06", center: { lat: 37.5006, lng: 127.0364 }, videoCount: 88, createdAt: "2026-06-30T09:00:00.000Z", durationSec: 59 }, - { id: "F-09", label: "잠실 F-09", center: { lat: 37.5133, lng: 127.1 }, videoCount: 64, createdAt: "2026-07-11T09:00:00.000Z", durationSec: 148 }, - { id: "F-10", label: "송파 F-10", center: { lat: 37.5145, lng: 127.106 }, videoCount: 21, createdAt: "2026-06-25T09:00:00.000Z" }, - { id: "G-03", label: "종로 G-03", center: { lat: 37.5729, lng: 126.9793 }, videoCount: 97, createdAt: "2026-07-13T09:00:00.000Z", durationSec: 302 }, - { id: "G-04", label: "광화문 G-04", center: { lat: 37.5716, lng: 126.9769 }, videoCount: 142, createdAt: "2026-07-03T09:00:00.000Z", durationSec: 75 }, - { id: "H-11", label: "여의도 H-11", center: { lat: 37.5219, lng: 126.9245 }, videoCount: 58, createdAt: "2026-07-06T09:00:00.000Z", durationSec: 41 }, - { id: "H-12", label: "노량진 H-12", center: { lat: 37.5136, lng: 126.9425 }, videoCount: 12, createdAt: "2026-06-18T09:00:00.000Z" }, + { id: "A-14", label: "홍대입구 A-14", district: "마포구", center: { lat: 37.5573, lng: 126.9245 }, videoCount: 138, createdAt: "2026-07-10T09:00:00.000Z", durationSec: 24 }, + { id: "A-15", label: "합정 A-15", district: "마포구", center: { lat: 37.5495, lng: 126.9137 }, videoCount: 72, createdAt: "2026-06-28T09:00:00.000Z", durationSec: 84 }, + { id: "B-07", label: "망원 B-07", district: "마포구", center: { lat: 37.5556, lng: 126.9016 }, videoCount: 54, createdAt: "2026-07-05T09:00:00.000Z" }, + { id: "B-08", label: "연남 B-08", district: "마포구", center: { lat: 37.5631, lng: 126.9256 }, videoCount: 91, createdAt: "2026-07-14T09:00:00.000Z", durationSec: 132 }, + { id: "C-02", label: "성수 C-02", district: "성동구", center: { lat: 37.5446, lng: 127.0559 }, videoCount: 205, createdAt: "2026-07-01T09:00:00.000Z", durationSec: 605 }, + { id: "C-03", label: "건대입구 C-03", district: "성동구", center: { lat: 37.5402, lng: 127.0702 }, videoCount: 47, createdAt: "2026-06-20T09:00:00.000Z", durationSec: 47 }, + { id: "D-01", label: "이태원 D-01", district: "용산구", center: { lat: 37.5346, lng: 126.9946 }, videoCount: 119, createdAt: "2026-07-12T09:00:00.000Z", durationSec: 210 }, + { id: "D-02", label: "한남 D-02", district: "용산구", center: { lat: 37.5344, lng: 127.0016 }, videoCount: 33, createdAt: "2026-06-15T09:00:00.000Z" }, + { id: "E-05", label: "강남역 E-05", district: "강남구", center: { lat: 37.4979, lng: 127.0276 }, videoCount: 176, createdAt: "2026-07-08T09:00:00.000Z", durationSec: 366 }, + { id: "E-06", label: "역삼 E-06", district: "강남구", center: { lat: 37.5006, lng: 127.0364 }, videoCount: 88, createdAt: "2026-06-30T09:00:00.000Z", durationSec: 59 }, + { id: "F-09", label: "잠실 F-09", district: "송파구", center: { lat: 37.5133, lng: 127.1 }, videoCount: 64, createdAt: "2026-07-11T09:00:00.000Z", durationSec: 148 }, + { id: "F-10", label: "송파 F-10", district: "송파구", center: { lat: 37.5145, lng: 127.106 }, videoCount: 21, createdAt: "2026-06-25T09:00:00.000Z" }, + { id: "G-03", label: "종로 G-03", district: "종로구", center: { lat: 37.5729, lng: 126.9793 }, videoCount: 97, createdAt: "2026-07-13T09:00:00.000Z", durationSec: 302 }, + { id: "G-04", label: "광화문 G-04", district: "종로구", center: { lat: 37.5716, lng: 126.9769 }, videoCount: 142, createdAt: "2026-07-03T09:00:00.000Z", durationSec: 75 }, + { id: "H-11", label: "여의도 H-11", district: "영등포구", center: { lat: 37.5219, lng: 126.9245 }, videoCount: 58, createdAt: "2026-07-06T09:00:00.000Z", durationSec: 41 }, + { id: "H-12", label: "노량진 H-12", district: "영등포구", center: { lat: 37.5136, lng: 126.9425 }, videoCount: 12, createdAt: "2026-06-18T09:00:00.000Z" }, ]; diff --git a/apps/web/src/entities/region/index.ts b/apps/web/src/entities/region/index.ts new file mode 100644 index 00000000..d7b82120 --- /dev/null +++ b/apps/web/src/entities/region/index.ts @@ -0,0 +1,2 @@ +export type { Region } from "./model/regions"; +export { MOCK_RECENT_VISITS, selectRegions } from "./model/regions"; diff --git a/apps/web/src/entities/region/model/regions.test.ts b/apps/web/src/entities/region/model/regions.test.ts new file mode 100644 index 00000000..e801d828 --- /dev/null +++ b/apps/web/src/entities/region/model/regions.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { MOCK_RECENT_VISITS, selectRegions } from "./regions"; + +describe("selectRegions 전체 지역 셀렉터 (AC 15)", () => { + it("각 구를 격자 수(count)와 함께 반환한다", () => { + for (const region of selectRegions()) { + expect(typeof region.name).toBe("string"); + expect(region.name.length).toBeGreaterThan(0); + expect(typeof region.count).toBe("number"); + expect(region.count).toBeGreaterThan(0); + } + }); + + it("Figma 목업 순서 그대로 8개 구를 반환한다", () => { + expect(selectRegions().map((r) => r.name)).toEqual([ + "마포구", + "강남구", + "성동구", + "용산구", + "영등포구", + "송파구", + "관악구", + "종로구", + ]); + }); +}); + +describe("MOCK_RECENT_VISITS 최근 방문 목데이터 (AC 2)", () => { + it("최근 방문 지역은 마포구·성동구·용산구다", () => { + expect(MOCK_RECENT_VISITS).toEqual(["마포구", "성동구", "용산구"]); + }); + + it("최근 방문 지역은 전체 지역 목록에 존재하는 구다", () => { + const names = new Set(selectRegions().map((r) => r.name)); + for (const visit of MOCK_RECENT_VISITS) { + expect(names.has(visit)).toBe(true); + } + }); +}); diff --git a/apps/web/src/entities/region/model/regions.ts b/apps/web/src/entities/region/model/regions.ts new file mode 100644 index 00000000..81162e72 --- /dev/null +++ b/apps/web/src/entities/region/model/regions.ts @@ -0,0 +1,33 @@ +/** 행정구(區) 도메인 모델 — 전체 지역 목록의 한 행 (MSG-114) */ +export interface Region { + /** 구 이름 (예: "마포구") — Cell.district 매칭 키 */ + name: string; + /** 격자 수 (목값) — 전체 지역 행의 "격자 N" 표시용 */ + count: number; +} + +/** + * 전체 지역 mock 데이터 — Figma 목업(node 13399-1795)의 8개 구, 표기 순서 그대로. + * count는 목값(실 집계 전 임시). Cell.district(mock-cells)와 name을 일치시켜 지역 필터가 동작한다. + */ +const MOCK_REGIONS: Region[] = [ + { name: "마포구", count: 1240 }, + { name: "강남구", count: 2180 }, + { name: "성동구", count: 980 }, + { name: "용산구", count: 760 }, + { name: "영등포구", count: 1120 }, + { name: "송파구", count: 1640 }, + { name: "관악구", count: 890 }, + { name: "종로구", count: 540 }, +]; + +/** + * 최근 방문 지역 mock 데이터 (비영속) — Figma 목업 기준 마포구·성동구·용산구. [AC 2] + * MOCK_REGIONS의 구 이름과 동일해, 칩 클릭 시 전체 지역 행 클릭과 같은 지역 필터로 동작한다(D4). + */ +export const MOCK_RECENT_VISITS: string[] = ["마포구", "성동구", "용산구"]; + +/** + * 전체 지역 목록을 각 구의 격자 수(count)와 함께, 정해진 순서로 반환한다(목데이터). [AC 15] + */ +export const selectRegions = (): Region[] => MOCK_REGIONS; diff --git a/apps/web/src/features/explore/model/explore-cells.test.ts b/apps/web/src/features/explore/model/explore-cells.test.ts index 168f7efb..d6c922d9 100644 --- a/apps/web/src/features/explore/model/explore-cells.test.ts +++ b/apps/web/src/features/explore/model/explore-cells.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Cell } from "@/entities/cell"; import { + filterByDistrict, formatDuration, searchCells, selectExploreCells, @@ -12,9 +13,11 @@ const cell = ( label: string, videoCount: number, createdAt: string, + district = "마포구", ): Cell => ({ id, label, + district, center: { lat: 0, lng: 0 }, videoCount, createdAt, @@ -118,6 +121,62 @@ describe("formatDuration (L3)", () => { }); }); +describe("filterByDistrict 지역 필터 (AC 16)", () => { + const cells = [ + cell("A", "홍대입구 A-14", 10, "2026-01-01T00:00:00.000Z", "마포구"), + cell("B", "합정 A-15", 20, "2026-01-02T00:00:00.000Z", "마포구"), + cell("C", "성수 C-02", 30, "2026-01-03T00:00:00.000Z", "성동구"), + cell("D", "강남역 E-05", 40, "2026-01-04T00:00:00.000Z", "강남구"), + ]; + + it("지정한 구(district)에 속한 격자만 반환한다", () => { + expect(filterByDistrict(cells, "마포구").map((c) => c.id)).toEqual([ + "A", + "B", + ]); + }); + + it("매칭되는 격자가 없는 구는 빈 배열을 반환한다(범위 밖 셀 제거)", () => { + expect(filterByDistrict(cells, "관악구")).toEqual([]); + }); + + it("district가 null이거나 undefined면 입력 목록을 그대로 반환한다", () => { + expect(filterByDistrict(cells, null)).toEqual(cells); + expect(filterByDistrict(cells, undefined)).toEqual(cells); + }); +}); + +describe("selectExploreCells 지역 필터 통합 (AC 16)", () => { + const cells = [ + cell("A", "홍대입구 A-14", 10, "2026-01-01T00:00:00.000Z", "마포구"), + cell("B", "합정 A-15", 50, "2026-01-02T00:00:00.000Z", "마포구"), + cell("C", "성수 C-02", 30, "2026-01-03T00:00:00.000Z", "성동구"), + ]; + + it("설정된 지역 필터가 결과 집합을 해당 구로 좁힌다", () => { + const result = selectExploreCells(cells, { + query: "", + order: "popular", + district: "마포구", + }); + expect(result.map((c) => c.id)).toEqual(["B", "A"]); + }); + + it("district 미지정 시 기존 동작(전체 대상)을 유지한다 — 시그니처 호환", () => { + const result = selectExploreCells(cells, { query: "", order: "popular" }); + expect(result.map((c) => c.id)).toEqual(["B", "C", "A"]); + }); + + it("지역 필터와 검색어 필터가 함께 적용된다(교집합)", () => { + const result = selectExploreCells(cells, { + query: "합정", + order: "popular", + district: "마포구", + }); + expect(result.map((c) => c.id)).toEqual(["B"]); + }); +}); + describe("selectExploreCells 검색+정렬 파이프라인 (L4)", () => { const cells = [ cell("A", "홍대입구 A-14", 10, "2026-01-03T00:00:00.000Z"), diff --git a/apps/web/src/features/explore/model/explore-cells.ts b/apps/web/src/features/explore/model/explore-cells.ts index 5d04e99e..22df4245 100644 --- a/apps/web/src/features/explore/model/explore-cells.ts +++ b/apps/web/src/features/explore/model/explore-cells.ts @@ -7,8 +7,23 @@ export type SortOrder = "popular" | "recent"; export interface ExploreQuery { query: string; order: SortOrder; + /** 선택된 행정구 필터 — null/undefined면 지역 필터 미적용 (MSG-114 D1/AC 16) */ + district?: string | null; } +/** + * 선택된 구(district)에 속한 격자만 반환한다. [AC 16] + * district가 없으면(null/undefined) 입력 목록을 그대로 반환한다 — 지역 필터는 결과 "집합"만 좁힌다. + * 순수 함수 — 지도 SDK/플랫폼에 의존하지 않는다(RN 재사용 대상). + */ +export const filterByDistrict = ( + cells: Cell[], + district?: string | null, +): Cell[] => { + if (!district) return cells; + return cells.filter((cell) => cell.district === district); +}; + /** * 검색어를 label(동네명+코드)의 부분 문자열로 매칭한다(대소문자 무시). [L1] * 빈/공백 검색어는 입력 목록을 그대로 반환한다 — 검색은 결과 "집합"만 결정한다. @@ -44,11 +59,13 @@ export const formatDuration = (sec?: number): string | null => { }; /** - * 검색 → 정렬 파이프라인. [L4] - * 정렬 상태를 바꿔도 동일 검색어의 결과 집합(원소)은 동일하다 — - * 검색은 집합만, 정렬은 순서만 결정한다. + * 지역 → 검색 → 정렬 파이프라인. [L4 + AC 16] + * 지역 필터(집합)와 검색 필터(집합)를 순차 교집합으로 좁힌 뒤 정렬(순서)한다. + * 정렬 상태를 바꿔도 동일 필터의 결과 집합(원소)은 동일하다. + * district 미지정 시 기존 검색+정렬 동작과 동일하다(시그니처 호환). */ export const selectExploreCells = ( cells: Cell[], - { query, order }: ExploreQuery, -): Cell[] => sortCells(searchCells(cells, query), order); + { query, order, district }: ExploreQuery, +): Cell[] => + sortCells(searchCells(filterByDistrict(cells, district), query), order); diff --git a/apps/web/src/features/explore/model/explore-filter-store.test.ts b/apps/web/src/features/explore/model/explore-filter-store.test.ts new file mode 100644 index 00000000..def47101 --- /dev/null +++ b/apps/web/src/features/explore/model/explore-filter-store.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { MOCK_CELLS } from "@/entities/cell"; +import { selectExploreCells } from "./explore-cells"; +import { useExploreFilterStore } from "./explore-filter-store"; + +const reset = () => useExploreFilterStore.getState().reset(); +const state = () => useExploreFilterStore.getState(); + +describe("explore-filter-store 액션", () => { + beforeEach(reset); + + it("applySearch: 검색어를 query에 반영하고 최근 검색 맨 앞에 추가한다 (AC 6, 11)", () => { + state().applySearch("성수"); + expect(state().query).toBe("성수"); + expect(state().recentSearches[0]).toBe("성수"); + }); + + it("applySearch: 검색은 지역 선택을 초기화한다(단일 필터 기준)", () => { + state().selectRegion("마포구"); + state().applySearch("성수"); + expect(state().selectedRegion).toBeNull(); + }); + + it("selectRegion: 지역을 선택하면 검색어를 초기화한다 (AC 5, D4)", () => { + state().applySearch("성수"); + state().selectRegion("마포구"); + expect(state().selectedRegion).toBe("마포구"); + expect(state().query).toBe(""); + }); + + it("removeRecentSearch: 지정 항목만 제거하고 나머지 순서는 보존한다 (AC 7, 12)", () => { + state().clearRecentSearches(); + state().applySearch("강남"); + state().applySearch("성수"); + state().applySearch("홍대"); + state().removeRecentSearch("성수"); + expect(state().recentSearches).toEqual(["홍대", "강남"]); + }); + + it("clearRecentSearches: 최근 검색을 모두 비운다 (AC 8, 13)", () => { + state().applySearch("성수"); + state().clearRecentSearches(); + expect(state().recentSearches).toEqual([]); + }); +}); + +describe("지역/검색 필터가 selectExploreCells 입력에 반영된다 (AC 16)", () => { + beforeEach(reset); + + it("지역 선택 시 결과가 해당 구의 격자로 좁혀진다", () => { + state().selectRegion("마포구"); + const { query, selectedRegion } = state(); + const result = selectExploreCells(MOCK_CELLS, { + query, + order: "popular", + district: selectedRegion, + }); + expect(result.length).toBeGreaterThan(0); + expect(result.every((c) => c.district === "마포구")).toBe(true); + }); + + it("검색어 선택 시 결과가 해당 검색어 매칭 격자로 좁혀진다", () => { + state().applySearch("성수"); + const { query, selectedRegion } = state(); + const result = selectExploreCells(MOCK_CELLS, { + query, + order: "popular", + district: selectedRegion, + }); + expect(result.every((c) => c.label.includes("성수"))).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/src/features/explore/model/explore-filter-store.ts b/apps/web/src/features/explore/model/explore-filter-store.ts new file mode 100644 index 00000000..2b9ed6d4 --- /dev/null +++ b/apps/web/src/features/explore/model/explore-filter-store.ts @@ -0,0 +1,60 @@ +import { create } from "zustand"; +import { MOCK_RECENT_VISITS } from "@/entities/region"; +import * as history from "./recent-history"; + +/** + * 최근 검색 mock 시드 (비영속, D3) — 새로고침 시 이 목데이터로 리셋된다. + * 라벨 부분일치 검색(searchCells)에 걸리는 용어로 두어 클릭 시 결과가 좁혀지는 것을 확인할 수 있다. + */ +const MOCK_RECENT_SEARCHES = ["성수", "홍대입구", "강남역"]; + +interface ExploreFilterState { + /** 커밋된 검색어 — 탐색 패널 카드 그리드 필터 입력 */ + query: string; + /** 선택된 행정구 필터 — null이면 지역 필터 미적용 */ + selectedRegion: string | null; + /** 최근 방문 지역 (비영속 목데이터) */ + recentVisits: string[]; + /** 최근 검색어 (비영속, 최신순 상단) */ + recentSearches: string[]; + /** 검색어를 커밋한다(엔터/검색 아이콘) — 지역 필터를 초기화하고 최근 검색에 추가한다 (D2) */ + applySearch: (term: string) => void; + /** 지역을 선택한다(전체 지역 행·최근 방문 칩) — 검색어를 초기화한다 (D4) */ + selectRegion: (district: string) => void; + /** 최근 검색에서 한 항목만 제거한다 (AC 7) */ + removeRecentSearch: (term: string) => void; + /** 최근 검색을 모두 비운다 (AC 8) */ + clearRecentSearches: () => void; + /** 초기 목데이터 상태로 되돌린다 (테스트/새로고침 시맨틱) */ + reset: () => void; +} + +const initialState = { + query: "", + selectedRegion: null as string | null, + recentVisits: MOCK_RECENT_VISITS, + recentSearches: MOCK_RECENT_SEARCHES, +}; + +/** + * 탐색 필터 스토어 (비영속 인메모리, D3) — 검색어·선택 지역·최근 기록을 보관한다. + * 상태 전이 로직은 순수 헬퍼(recent-history)로 분리해 테스트한다(AC 11~14). + * 플랫폼 API(window/localStorage/router)를 참조하지 않는다 — RN 경계. + */ +export const useExploreFilterStore = create((set) => ({ + ...initialState, + applySearch: (term) => + set((s) => ({ + query: term.trim(), + selectedRegion: null, + recentSearches: history.addRecentSearch(s.recentSearches, term), + })), + selectRegion: (district) => set({ selectedRegion: district, query: "" }), + removeRecentSearch: (term) => + set((s) => ({ + recentSearches: history.removeRecentSearch(s.recentSearches, term), + })), + clearRecentSearches: () => + set({ recentSearches: history.clearRecentSearches() }), + reset: () => set(initialState), +})); diff --git a/apps/web/src/features/explore/model/recent-history.test.ts b/apps/web/src/features/explore/model/recent-history.test.ts new file mode 100644 index 00000000..9726876f --- /dev/null +++ b/apps/web/src/features/explore/model/recent-history.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + addRecentSearch, + clearRecentSearches, + isHistoryEmpty, + MAX_RECENT_SEARCHES, + removeRecentSearch, +} from "./recent-history"; + +describe("addRecentSearch (AC 11)", () => { + it("검색어를 최근 검색 목록 맨 앞에 추가한다", () => { + expect(addRecentSearch(["성수", "강남"], "홍대")).toEqual([ + "홍대", + "성수", + "강남", + ]); + }); + + it("이미 존재하는 검색어는 중복 없이 맨 앞(최신)으로 이동시킨다", () => { + expect(addRecentSearch(["성수", "강남", "홍대"], "강남")).toEqual([ + "강남", + "성수", + "홍대", + ]); + }); + + it("상한(10개)을 초과하면 가장 오래된 항목을 버린다", () => { + const full = Array.from({ length: MAX_RECENT_SEARCHES }, (_, i) => `검색${i}`); + const result = addRecentSearch(full, "새검색"); + expect(result).toHaveLength(MAX_RECENT_SEARCHES); + expect(result[0]).toBe("새검색"); + expect(result).not.toContain(`검색${MAX_RECENT_SEARCHES - 1}`); + }); + + it("앞뒤 공백을 제거해 추가한다", () => { + expect(addRecentSearch([], " 성수 ")).toEqual(["성수"]); + }); + + it("빈/공백 검색어는 무시하고 목록을 그대로 반환한다", () => { + const list = ["성수"]; + expect(addRecentSearch(list, "")).toEqual(list); + expect(addRecentSearch(list, " ")).toEqual(list); + }); +}); + +describe("removeRecentSearch (AC 12)", () => { + it("지정한 항목만 제거하고 나머지 항목의 상대 순서는 보존한다", () => { + expect(removeRecentSearch(["홍대", "성수", "강남"], "성수")).toEqual([ + "홍대", + "강남", + ]); + }); + + it("존재하지 않는 항목 제거 시 목록을 그대로 반환한다", () => { + expect(removeRecentSearch(["홍대", "성수"], "없음")).toEqual([ + "홍대", + "성수", + ]); + }); +}); + +describe("clearRecentSearches (AC 13)", () => { + it("최근 검색 목록을 빈 배열로 만든다", () => { + expect(clearRecentSearches()).toEqual([]); + }); +}); + +describe("isHistoryEmpty 빈 상태 판정 (AC 14)", () => { + it("목록 길이가 0이면 true를 반환한다", () => { + expect(isHistoryEmpty([])).toBe(true); + }); + + it("항목이 하나라도 있으면 false를 반환한다", () => { + expect(isHistoryEmpty(["성수"])).toBe(false); + }); +}); diff --git a/apps/web/src/features/explore/model/recent-history.ts b/apps/web/src/features/explore/model/recent-history.ts new file mode 100644 index 00000000..62566cc2 --- /dev/null +++ b/apps/web/src/features/explore/model/recent-history.ts @@ -0,0 +1,36 @@ +/** + * 최근 검색 목록 상태 전이 — 순수 함수 (MSG-114 AC 11~14). + * 스토어(explore-filter-store)가 이 헬퍼로 최근 검색을 갱신한다. + * 플랫폼 API(window/localStorage 등)를 참조하지 않는다 — RN 재사용 대상. + */ + +/** 최근 검색 보관 상한 (최신순 상단, 초과 시 오래된 항목부터 제거) */ +export const MAX_RECENT_SEARCHES = 10; + +/** + * 검색어를 최근 검색 목록 맨 앞에 추가한다. [AC 11] + * 이미 존재하는 검색어는 중복 없이 맨 앞(최신)으로 이동시키고, 상한을 넘으면 오래된 항목을 버린다. + * 빈/공백 검색어는 무시한다. + */ +export const addRecentSearch = ( + searches: string[], + term: string, +): string[] => { + const t = term.trim(); + if (!t) return searches; + return [t, ...searches.filter((s) => s !== t)].slice(0, MAX_RECENT_SEARCHES); +}; + +/** + * 지정한 항목만 제거하고 나머지 항목의 상대 순서는 보존한다. [AC 12] + */ +export const removeRecentSearch = ( + searches: string[], + term: string, +): string[] => searches.filter((s) => s !== term); + +/** 최근 검색 목록을 빈 배열로 만든다. [AC 13] */ +export const clearRecentSearches = (): string[] => []; + +/** 목록 길이가 0이면 "빈 상태" 신호(true)를 반환한다. [AC 14] */ +export const isHistoryEmpty = (list: string[]): boolean => list.length === 0; diff --git a/apps/web/src/features/map-home/model/cell-viewport.test.ts b/apps/web/src/features/map-home/model/cell-viewport.test.ts index afcff400..875d0f97 100644 --- a/apps/web/src/features/map-home/model/cell-viewport.test.ts +++ b/apps/web/src/features/map-home/model/cell-viewport.test.ts @@ -9,6 +9,7 @@ import { const cell = (id: string, lat: number, lng: number, videoCount: number): Cell => ({ id, label: id, + district: "마포구", center: { lat, lng }, videoCount, createdAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index 37a98181..0b3bfd52 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -5,6 +5,7 @@ import { selectExploreCells, type SortOrder, } from "@/features/explore/model/explore-cells"; +import { useExploreFilterStore } from "@/features/explore/model/explore-filter-store"; import { filterCellsInBounds, summarizeCells, @@ -13,6 +14,7 @@ import { useCellsQuery } from "@/features/map-home/model/use-cells-query"; import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { useMapShell } from "@/widgets/map-shell/use-map-shell"; import { ExploreCellCard } from "./ui/ExploreCellCard"; +import { SearchPanel } from "./ui/SearchPanel"; import { SortChip } from "./ui/SortChip"; /** S4 지역명 — 뷰포트→행정구역명 변환은 범위 밖, 고정 목값 표시(D4) */ @@ -28,42 +30,53 @@ export const ExplorePanel = () => { const bounds = useViewportStore((s) => s.bounds); const { data, isLoading, isError, refetch } = useCellsQuery(); - const [query, setQuery] = useState(""); + // 검색어·선택 지역은 공유 필터 스토어로 승격(MSG-114) — 정렬은 이 화면 로컬 유지(MSG-113) + const query = useExploreFilterStore((s) => s.query); + const selectedRegion = useExploreFilterStore((s) => s.selectedRegion); const [order, setOrder] = useState("popular"); + const [searchOpen, setSearchOpen] = useState(false); return ( - + refetch()} + query={query} + order={order} + district={selectedRegion} + onCellSelect={moveTo} + /> + + + {searchOpen && setSearchOpen(false)} />} + ); }; @@ -75,6 +88,8 @@ interface ExploreBodyProps { onRetry: () => void; query: string; order: SortOrder; + /** 선택된 지역 필터 — null이면 지역 필터 미적용 (MSG-114) */ + district: string | null; onCellSelect: (center: LatLng) => void; } @@ -87,6 +102,7 @@ const ExploreBody = ({ onRetry, query, order, + district, onCellSelect, }: ExploreBodyProps) => { // early return(isError·isLoading) 아래에 두면 렌더마다 훅 호출 여부가 달라져 @@ -97,8 +113,8 @@ const ExploreBody = ({ ); const { cellCount } = useMemo(() => summarizeCells(visibleCells), [visibleCells]); const displayCells = useMemo( - () => selectExploreCells(visibleCells, { query, order }), - [visibleCells, query, order], + () => selectExploreCells(visibleCells, { query, order, district }), + [visibleCells, query, order, district], ); if (isError) { @@ -129,11 +145,13 @@ const ExploreBody = ({ return ( <>
- {REGION_LABEL} + + {district ? `서울 ${district} 격자` : REGION_LABEL} + {cellCount}개
-
+
{displayCells.length === 0 ? (

{query.trim() diff --git a/apps/web/src/pages/explore/ui/RecentSearchRow.tsx b/apps/web/src/pages/explore/ui/RecentSearchRow.tsx new file mode 100644 index 00000000..ac2d470e --- /dev/null +++ b/apps/web/src/pages/explore/ui/RecentSearchRow.tsx @@ -0,0 +1,39 @@ +import { Clock, X } from "lucide-react"; + +interface RecentSearchRowProps { + /** 최근 검색어 */ + term: string; + /** 검색어 클릭 시 그 검색어로 필터 적용 (AC 6) */ + onSelect: (term: string) => void; + /** ✕ 클릭 시 해당 항목만 제거 (AC 7) */ + onRemove: (term: string) => void; +} + +/** + * 최근 검색 행 — 원형 아이콘 + 검색어 + 개별 삭제(✕). (AC 3/6/7) + * 검색(행 클릭)과 삭제(✕)를 형제 버튼으로 분리해 이벤트 버블링 충돌을 피한다. + */ +export const RecentSearchRow = ({ + term, + onSelect, + onRemove, +}: RecentSearchRowProps) => ( +

+ + +
+); diff --git a/apps/web/src/pages/explore/ui/RegionRow.tsx b/apps/web/src/pages/explore/ui/RegionRow.tsx new file mode 100644 index 00000000..e8bf17ff --- /dev/null +++ b/apps/web/src/pages/explore/ui/RegionRow.tsx @@ -0,0 +1,30 @@ +import { ChevronRight } from "lucide-react"; + +interface RegionRowProps { + /** 구 이름 (예: "마포구") */ + name: string; + /** 격자 수 (목값) */ + count: number; + /** 행 클릭 시 해당 지역 필터 적용 (AC 5) */ + onSelect: (name: string) => void; +} + +/** + * 전체 지역 행 — 구 이름 + "격자 N" 개수 + 오른쪽 화살표(›). (AC 4) + * 이 화면 전용 조합이라 로컬로 둔다(승격 후보 아님). + */ +export const RegionRow = ({ name, count, onSelect }: RegionRowProps) => ( + +); diff --git a/apps/web/src/pages/explore/ui/SearchPanel.tsx b/apps/web/src/pages/explore/ui/SearchPanel.tsx new file mode 100644 index 00000000..5bd55c57 --- /dev/null +++ b/apps/web/src/pages/explore/ui/SearchPanel.tsx @@ -0,0 +1,160 @@ +import { useMemo, useState } from "react"; +import { ChevronLeft, Search } from "lucide-react"; +import { Chip } from "@fillmap/ui-web"; +import { selectRegions } from "@/entities/region"; +import { useExploreFilterStore } from "@/features/explore/model/explore-filter-store"; +import { isHistoryEmpty } from "@/features/explore/model/recent-history"; +import { RecentSearchRow } from "./RecentSearchRow"; +import { RegionRow } from "./RegionRow"; + +interface SearchPanelProps { + /** 패널 닫기(뒤로가기·바깥 클릭·필터 선택 후 복귀) (AC 10) */ + onClose: () => void; +} + +/** + * 검색 패널 오버레이 — SearchBar 클릭 시 탐색 패널 위에 뜨는 떠 있는 카드(Figma 13399-1795). + * 뒤로가기 + 입력창 + 최근 방문 칩 + 최근 검색 리스트 + 전체 지역 목록으로 구성된다. + * 타이핑 후 엔터/검색 아이콘으로 검색을 커밋하고 필터가 적용된 탐색 패널로 복귀한다(D2). + * 바깥 영역 클릭으로 닫힌다(모달 시맨틱, AC 10). + */ +export const SearchPanel = ({ onClose }: SearchPanelProps) => { + const recentVisits = useExploreFilterStore((s) => s.recentVisits); + const recentSearches = useExploreFilterStore((s) => s.recentSearches); + const applySearch = useExploreFilterStore((s) => s.applySearch); + const selectRegion = useExploreFilterStore((s) => s.selectRegion); + const removeRecentSearch = useExploreFilterStore((s) => s.removeRecentSearch); + const clearRecentSearches = useExploreFilterStore( + (s) => s.clearRecentSearches, + ); + const regions = useMemo(() => selectRegions(), []); + const [input, setInput] = useState(""); + + const commitSearch = (term: string) => { + if (!term.trim()) return; + applySearch(term); + onClose(); + }; + + const chooseRegion = (name: string) => { + selectRegion(name); + onClose(); + }; + + const visitsEmpty = isHistoryEmpty(recentVisits); + const searchesEmpty = isHistoryEmpty(recentSearches); + + return ( + // 바깥 클릭 닫기용 백드롭 — 탐색 패널·지도 영역을 덮는다(AC 10) +
+
e.stopPropagation()} + className="absolute bottom-md left-md top-md flex w-100 flex-col gap-md rounded-lg bg-background p-md shadow-raised" + > + {/* 검색 행: 뒤로가기 + 입력 필 */} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitSearch(input); + }} + placeholder="장소, 격자, 영상 검색" + className="min-w-0 flex-1 bg-transparent text-fm-base text-foreground outline-none placeholder:text-foreground-muted" + /> + +
+
+ +
+ {/* 최근 방문 */} +
+

최근 방문

+ {visitsEmpty ? ( +

+ 최근 방문한 지역이 없어요. +

+ ) : ( +
+ {recentVisits.map((visit) => ( + chooseRegion(visit)} + /> + ))} +
+ )} +
+ + {/* 최근 검색 */} +
+
+

최근 검색

+ {!searchesEmpty && ( + + )} +
+ {searchesEmpty ? ( +

+ 최근 검색 기록이 없어요. +

+ ) : ( +
+ {recentSearches.map((term) => ( + + ))} +
+ )} +
+ + {/* 전체 지역 */} +
+

전체 지역

+
+ {regions.map((region) => ( + + ))} +
+
+
+
+
+ ); +}; diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index bc2a5ea2..107a5187 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -12,3 +12,5 @@ | 2026-07-16 | MSG-112 | 발견: 카카오맵 SDK가 `http://localhost` 출처의 요청을 503으로 거부(콘솔 도메인 등록과 별개) — dev에서 지도를 보려면 https 서빙 필요 | 검증 중 네트워크 계측으로 확인: 같은 키로 https·무Referer 요청은 200, `Referer: http://localhost:5173` 요청은 503. SDK가 프로토콜 상대 URL을 써서 http 페이지에선 http로 요청됨. dev https화(예: vite basic-ssl)는 별도 티켓 권장 | | 2026-07-17 | MSG-113 | 결정: 지도를 `MapHomePage` 소유에서 `MapShell`(라우트 상위 지속 셸)로 이관, `/`·`/explore`는 그 위에 오버레이만 스위칭 | 티켓이 "탐색 패널은 지도 위 오버레이, 지도는 뒤에서 계속 보임"을 요구했는데 기존 라우터는 `/explore` 전환 시 `MapHomePage`가 언마운트돼 지도가 사라짐. 지도 소유권을 셸로 올리면 홈↔탐색 왕복에도 지도 중심·줌이 유지됨(검증 단계에서 회귀 없음 확인) | | 2026-07-17 | MSG-113 | 결정: 카드 클릭 시 지도 "이동"만 구현, 지도 위 격자 "강조" 렌더링은 범위 밖으로 분리 | 티켓 문구는 강조를 "기존 로직 재사용"이라 했으나 실제 `MapCanvas`엔 강조 렌더링 자체가 없었음(이동만 존재) — 없는 것을 재사용할 수 없어 신규 구현 여부를 사용자에게 확인, 범위 확대 대신 후속 티켓으로 분리하기로 결정 | +| 2026-07-17 | MSG-114 | 결정: 지역 필터와 검색어 필터를 상호 배타(단일 필터)로 처리 — 지역 선택 시 `query=""`, 검색 커밋 시 `selectedRegion=null` | 두 필터를 동시 유지하면 "마포구 + 이전 검색어 '성수'" 같은 stale 교집합이 빈 결과를 내 사용자를 혼란시킴. 지역 브라우징과 텍스트 검색은 별개의 진입 동작이라 한쪽 선택이 다른 쪽을 리셋하는 것이 자연스러움. `selectExploreCells`는 두 필터 교집합을 지원하되(AC 16), 스토어가 배타 정책을 강제 | +| 2026-07-17 | MSG-114 | 결정: 검색 패널 입력창을 공용 `SearchBar` 재사용 대신 로컬 surface 필 조합으로 구현 | Figma 패널 입력은 flat surface 필(#f5f5f7)로 `SearchBar`(elevated bg-background+shadow, 장식용 아이콘)와 시각·구조가 다르고, D2가 "검색 아이콘 클릭 커밋"을 요구하는데 `SearchBar`의 아이콘은 클릭 불가. 얇은 로컬 input+버튼 조합이 요구를 정확히 충족(탐색 패널 트리거 SearchBar는 그대로 재사용) | From 3654e2d96a17fd108f250c21a13f8378a069a8ca Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 18 Jul 2026 10:28:13 +0900 Subject: [PATCH 026/281] =?UTF-8?q?MSG-114=20fix:=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EC=9E=AC=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94,=20=EC=B5=9C=EA=B7=BC=20=EB=B0=A9?= =?UTF-8?q?=EB=AC=B8=20=EC=B9=A9=20=EB=94=94=EC=9E=90=EC=9D=B8=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/explore/model/explore-filter-store.test.ts | 9 +++++++++ .../src/features/explore/model/explore-filter-store.ts | 3 +++ apps/web/src/pages/explore/ExplorePanel.tsx | 6 +++++- apps/web/src/pages/explore/ui/SearchPanel.tsx | 7 ++++--- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/explore/model/explore-filter-store.test.ts b/apps/web/src/features/explore/model/explore-filter-store.test.ts index def47101..b4eb4f16 100644 --- a/apps/web/src/features/explore/model/explore-filter-store.test.ts +++ b/apps/web/src/features/explore/model/explore-filter-store.test.ts @@ -42,6 +42,15 @@ describe("explore-filter-store 액션", () => { state().clearRecentSearches(); expect(state().recentSearches).toEqual([]); }); + + it("clearFilters: 검색어·지역 필터만 초기화하고 최근 기록은 유지한다 (탐색 재진입 시맨틱)", () => { + state().applySearch("성수"); + state().selectRegion("마포구"); + state().clearFilters(); + expect(state().query).toBe(""); + expect(state().selectedRegion).toBeNull(); + expect(state().recentSearches[0]).toBe("성수"); + }); }); describe("지역/검색 필터가 selectExploreCells 입력에 반영된다 (AC 16)", () => { diff --git a/apps/web/src/features/explore/model/explore-filter-store.ts b/apps/web/src/features/explore/model/explore-filter-store.ts index 2b9ed6d4..ef4203aa 100644 --- a/apps/web/src/features/explore/model/explore-filter-store.ts +++ b/apps/web/src/features/explore/model/explore-filter-store.ts @@ -25,6 +25,8 @@ interface ExploreFilterState { removeRecentSearch: (term: string) => void; /** 최근 검색을 모두 비운다 (AC 8) */ clearRecentSearches: () => void; + /** 검색어·지역 필터만 초기화한다 — 최근 기록은 유지 (탐색 재진입 시맨틱) */ + clearFilters: () => void; /** 초기 목데이터 상태로 되돌린다 (테스트/새로고침 시맨틱) */ reset: () => void; } @@ -56,5 +58,6 @@ export const useExploreFilterStore = create((set) => ({ })), clearRecentSearches: () => set({ recentSearches: history.clearRecentSearches() }), + clearFilters: () => set({ query: "", selectedRegion: null }), reset: () => set(initialState), })); diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index 0b3bfd52..aab02ea2 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { SearchBar } from "@fillmap/ui-web"; import type { Bounds, Cell, LatLng } from "@/entities/cell"; import { @@ -33,9 +33,13 @@ export const ExplorePanel = () => { // 검색어·선택 지역은 공유 필터 스토어로 승격(MSG-114) — 정렬은 이 화면 로컬 유지(MSG-113) const query = useExploreFilterStore((s) => s.query); const selectedRegion = useExploreFilterStore((s) => s.selectedRegion); + const clearFilters = useExploreFilterStore((s) => s.clearFilters); const [order, setOrder] = useState("popular"); const [searchOpen, setSearchOpen] = useState(false); + // 탐색 진입 시마다 이전 검색어·지역 필터 초기화 — 최근 기록은 유지 + useEffect(() => clearFilters(), [clearFilters]); + return ( <> ); }; diff --git a/apps/web/src/pages/explore/ui/SearchPanel.tsx b/apps/web/src/pages/explore/ui/SearchPanel.tsx index feaa92cd..ac09dcb8 100644 --- a/apps/web/src/pages/explore/ui/SearchPanel.tsx +++ b/apps/web/src/pages/explore/ui/SearchPanel.tsx @@ -8,17 +8,16 @@ import { RegionRow } from "./RegionRow"; import { SortChip } from "./SortChip"; interface SearchPanelProps { - /** 패널 닫기(뒤로가기·바깥 클릭·필터 선택 후 복귀) (AC 10) */ + /** 검색 모드 닫기(뒤로가기·필터 선택 후 복귀) */ onClose: () => void; } /** - * 검색 패널 오버레이 — SearchBar 클릭 시 탐색 패널 위를 같은 폭(388px)으로 덮는다. - * 검색창(입력 필)이 탐색 패널 SearchBar와 같은 자리에 오도록 배치해 전환 시 위치가 - * 튀지 않는다 — 이 제약 때문에 뒤로가기(‹)는 Figma(13399-1795)의 필 바깥이 아닌 - * 필 안쪽 좌측에 둔다. 내용은 최근 방문 칩 + 최근 검색 리스트 + 전체 지역 목록. - * 타이핑 후 엔터/검색 아이콘으로 검색을 커밋하고 필터가 적용된 탐색 패널로 복귀한다(D2). - * 바깥 영역 클릭으로 닫힌다(모달 시맨틱, AC 10). + * 검색 모드 인라인 콘텐츠 — 탐색 패널(aside) 안에서 검색바를 그 자리에 유지한 채 + * 아래 영역만 최근 방문·최근 검색·전체 지역으로 바꾼다(네이버/카카오 지도식 인라인 전개). + * 별도 오버레이·백드롭 없이 aside의 flex column 안에 그대로 들어간다. + * 검색 필은 탐색 패널 SearchBar와 같은 자리·형태(배경색만 surface로 구분)이며 좌측에 + * 뒤로가기(‹)를 둔다. 타이핑 후 엔터/검색 아이콘으로 커밋하면 필터가 적용된 목록으로 복귀한다(D2). */ export const SearchPanel = ({ onClose }: SearchPanelProps) => { const recentVisits = useExploreFilterStore((s) => s.recentVisits); @@ -47,16 +46,11 @@ export const SearchPanel = ({ onClose }: SearchPanelProps) => { const searchesEmpty = isHistoryEmpty(recentSearches); return ( - // 바깥 클릭 닫기용 백드롭 — 탐색 패널·지도 영역을 덮는다(AC 10) -
-
e.stopPropagation()} - className="absolute inset-y-0 left-0 flex w-97 flex-col gap-md bg-background p-md shadow-raised" - > - {/* 검색 필 — 탐색 패널 SearchBar와 같은 자리(h-12 풀폭), 뒤로가기는 필 안쪽 좌측 */} -
+ <> + {/* 검색 필 — 탐색 패널 SearchBar와 같은 자리(p-md)·형태, 배경색만 surface로 구분하고 + 좌측에 뒤로가기 버튼을 추가한다(전환 시 위치·모양이 튀지 않도록). */} +
+
+
-
- {/* 최근 방문 */} -
-

최근 방문

- {visitsEmpty ? ( -

- 최근 방문한 지역이 없어요. -

- ) : ( -
- {recentVisits.map((visit) => ( - chooseRegion(visit)} - /> - ))} -
- )} -
- - {/* 최근 검색 */} -
-
-

최근 검색

- {!searchesEmpty && ( - - )} +
+ {/* 최근 방문 */} +
+

최근 방문

+ {visitsEmpty ? ( +

+ 최근 방문한 지역이 없어요. +

+ ) : ( +
+ {recentVisits.map((visit) => ( + chooseRegion(visit)} + /> + ))}
- {searchesEmpty ? ( -

- 최근 검색 기록이 없어요. -

- ) : ( -
- {recentSearches.map((term) => ( - - ))} -
- )} -
+ )} +
- {/* 전체 지역 */} -
-

전체 지역

+ {/* 최근 검색 */} +
+
+

최근 검색

+ {!searchesEmpty && ( + + )} +
+ {searchesEmpty ? ( +

+ 최근 검색 기록이 없어요. +

+ ) : (
- {regions.map((region) => ( - ( + ))}
-
-
+ )} + + + {/* 전체 지역 */} +
+

전체 지역

+
+ {regions.map((region) => ( + + ))} +
+
-
+ ); }; From 953cc6b12b76cef1c3a8c45eb1c1a7caf5f2b6b8 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 18 Jul 2026 11:36:23 +0900 Subject: [PATCH 029/281] =?UTF-8?q?MSG-114=20feat:=20=EB=84=A4=EB=B9=84=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=EC=9D=84=20=EC=97=B4=EA=B3=A0=20=EB=8B=AB?= =?UTF-8?q?=EB=8A=94=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/router.tsx | 11 ++++--- apps/web/src/pages/explore/ExplorePanel.tsx | 7 +++- .../src/pages/placeholder/PlaceholderPage.tsx | 7 ---- .../widgets/section-panel/SectionPanel.tsx | 33 +++++++++++++++++++ .../section-panel/SidebarCloseHandle.tsx | 21 ++++++++++++ .../src/widgets/side-rail-nav/SideRailNav.tsx | 4 +-- 6 files changed, 68 insertions(+), 15 deletions(-) delete mode 100644 apps/web/src/pages/placeholder/PlaceholderPage.tsx create mode 100644 apps/web/src/widgets/section-panel/SectionPanel.tsx create mode 100644 apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 47be10ec..afbb180b 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -3,24 +3,25 @@ import { AppLayout } from "@/app/layouts/AppLayout"; import { ROUTES } from "@/app/routes"; import { ExplorePanel } from "@/pages/explore/ExplorePanel"; import { MapHomePage } from "@/pages/map-home/MapHomePage"; -import { PlaceholderPage } from "@/pages/placeholder/PlaceholderPage"; import { MapShell } from "@/widgets/map-shell/MapShell"; +import { SectionPanel } from "@/widgets/section-panel/SectionPanel"; export const router = createBrowserRouter([ { element: , children: [ - // 홈/탐색은 지속 지도 셸을 공유해 라우트 전환에도 지도가 유지된다(D1) + // 모든 네비 섹션이 지속 지도 셸을 공유한다 — 각 섹션은 지도 위 사이드바 패널로 열리고 + // 닫으면(홈으로 복귀) 지도가 넓게 보인다. 지도는 라우트 전환에도 유지된다(D1). { element: , children: [ { path: ROUTES.home, element: }, { path: ROUTES.explore, element: }, + { path: ROUTES.upload, element: }, + { path: ROUTES.dex, element: }, + { path: ROUTES.profile, element: }, ], }, - { path: ROUTES.upload, element: }, - { path: ROUTES.dex, element: }, - { path: ROUTES.profile, element: }, ], }, ]); diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index 4be0a519..1074d5d1 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { SearchBar } from "@fillmap/ui-web"; +import { ROUTES } from "@/app/routes"; import type { Bounds, Cell, LatLng } from "@/entities/cell"; import { selectExploreCells, @@ -14,6 +15,7 @@ import { import { useCellsQuery } from "@/features/map-home/model/use-cells-query"; import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { useMapShell } from "@/widgets/map-shell/use-map-shell"; +import { SidebarCloseHandle } from "@/widgets/section-panel/SidebarCloseHandle"; import { ExploreCellCard } from "./ui/ExploreCellCard"; import { SearchPanel } from "./ui/SearchPanel"; import { SortChip } from "./ui/SortChip"; @@ -27,6 +29,7 @@ const REGION_LABEL = "서울 마포구 격자"; * 검색·정렬 상태는 로컬로 관리하고, 목록 파생은 순수 셀렉터(selectExploreCells)에 위임한다. */ export const ExplorePanel = () => { + const navigate = useNavigate(); const { moveTo } = useMapShell(); const bounds = useViewportStore((s) => s.bounds); const { data, isLoading, isError, refetch } = useCellsQuery(); @@ -88,6 +91,8 @@ export const ExplorePanel = () => { /> )} + + navigate(ROUTES.home)} /> ); }; diff --git a/apps/web/src/pages/placeholder/PlaceholderPage.tsx b/apps/web/src/pages/placeholder/PlaceholderPage.tsx deleted file mode 100644 index 0ea37a58..00000000 --- a/apps/web/src/pages/placeholder/PlaceholderPage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -/** 아직 티켓이 착수되지 않은 섹션의 임시 페이지 — 각 섹션 구현 시 대체 */ -export const PlaceholderPage = ({ title }: { title: string }) => ( -
-

{title}

-

준비 중인 페이지예요

-
-); diff --git a/apps/web/src/widgets/section-panel/SectionPanel.tsx b/apps/web/src/widgets/section-panel/SectionPanel.tsx new file mode 100644 index 00000000..3403b194 --- /dev/null +++ b/apps/web/src/widgets/section-panel/SectionPanel.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { ROUTES } from "@/app/routes"; +import { SidebarCloseHandle } from "./SidebarCloseHandle"; + +interface SectionPanelProps { + title: string; + children?: ReactNode; +} + +/** + * 네비 섹션 공통 사이드바 패널 — 지속 지도 셸(MapShell) 위에 얹히는 388px 좌측 오버레이. + * 헤더(제목) + 본문 + 우측 접기 핸들(닫으면 홈/지도로 복귀)로 구성되며, 탐색 패널과 폭·위치를 맞춘다. + * 업로드·도감·프로필 등 아직 전용 화면이 없는 섹션이 이 래퍼로 사이드바를 얻는다. + */ +export const SectionPanel = ({ title, children }: SectionPanelProps) => { + const navigate = useNavigate(); + return ( + + ); +}; diff --git a/apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx b/apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx new file mode 100644 index 00000000..89fb192d --- /dev/null +++ b/apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx @@ -0,0 +1,21 @@ +import { ChevronLeft } from "lucide-react"; + +interface SidebarCloseHandleProps { + /** 패널 닫기 — 지도(홈)를 넓게 보여준다 */ + onClose: () => void; +} + +/** + * 사이드바 우측 가장자리에 붙는 접기 핸들 — 클릭 시 패널을 닫아 지도를 넓게 보여준다. + * 네비 아이콘을 다시 눌러도 같은 닫기 동작을 한다(SideRailNav의 활성 아이콘 토글). + */ +export const SidebarCloseHandle = ({ onClose }: SidebarCloseHandleProps) => ( + +); diff --git a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx index 341b60dc..23a0d452 100644 --- a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx +++ b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx @@ -27,8 +27,8 @@ export const SideRailNav = () => { activeKey={getActiveNavKey(pathname)} onSelect={(key) => { if (!isNavKey(key)) return; - // 탐색을 다시 누르면 패널을 닫는다 — 홈으로 복귀(S10) - if (key === "explore" && getActiveNavKey(pathname) === "explore") { + // 활성 섹션 아이콘을 다시 누르면 사이드바를 닫고 홈(지도)으로 복귀 — 열고 닫기 토글 + if (key !== "home" && key === getActiveNavKey(pathname)) { navigate(ROUTES.home); return; } From 61acddfdd060dcf3008292c8484611a1e42c1ead Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 18 Jul 2026 11:51:36 +0900 Subject: [PATCH 030/281] =?UTF-8?q?MSG-114=20refactor:=20=ED=99=88=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20=EC=A0=84=20=EC=84=B9=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=EA=B8=80=EB=A7=B5=EC=8B=9D=20collapse=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ExplorePanel.tsx | 7 +-- apps/web/src/pages/map-home/MapHomePage.tsx | 45 +++++++------------ apps/web/src/widgets/map-shell/MapShell.tsx | 31 ++++++++++++- .../map-shell/SidebarCollapseHandle.tsx | 30 +++++++++++++ .../src/widgets/map-shell/sidebar-store.ts | 18 ++++++++ .../widgets/section-panel/SectionPanel.tsx | 36 ++++++--------- .../section-panel/SidebarCloseHandle.tsx | 21 --------- .../src/widgets/side-rail-nav/SideRailNav.tsx | 15 +++++-- 8 files changed, 120 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/widgets/map-shell/SidebarCollapseHandle.tsx create mode 100644 apps/web/src/widgets/map-shell/sidebar-store.ts delete mode 100644 apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index 1074d5d1..4be0a519 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation } from "react-router-dom"; import { SearchBar } from "@fillmap/ui-web"; -import { ROUTES } from "@/app/routes"; import type { Bounds, Cell, LatLng } from "@/entities/cell"; import { selectExploreCells, @@ -15,7 +14,6 @@ import { import { useCellsQuery } from "@/features/map-home/model/use-cells-query"; import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { useMapShell } from "@/widgets/map-shell/use-map-shell"; -import { SidebarCloseHandle } from "@/widgets/section-panel/SidebarCloseHandle"; import { ExploreCellCard } from "./ui/ExploreCellCard"; import { SearchPanel } from "./ui/SearchPanel"; import { SortChip } from "./ui/SortChip"; @@ -29,7 +27,6 @@ const REGION_LABEL = "서울 마포구 격자"; * 검색·정렬 상태는 로컬로 관리하고, 목록 파생은 순수 셀렉터(selectExploreCells)에 위임한다. */ export const ExplorePanel = () => { - const navigate = useNavigate(); const { moveTo } = useMapShell(); const bounds = useViewportStore((s) => s.bounds); const { data, isLoading, isError, refetch } = useCellsQuery(); @@ -91,8 +88,6 @@ export const ExplorePanel = () => { /> )} - - navigate(ROUTES.home)} /> ); }; diff --git a/apps/web/src/pages/map-home/MapHomePage.tsx b/apps/web/src/pages/map-home/MapHomePage.tsx index b1ac5435..a55fb0e1 100644 --- a/apps/web/src/pages/map-home/MapHomePage.tsx +++ b/apps/web/src/pages/map-home/MapHomePage.tsx @@ -3,40 +3,29 @@ import { SearchBar } from "@fillmap/ui-web"; import { ROUTES } from "@/app/routes"; import { useMapShell } from "@/widgets/map-shell/use-map-shell"; import { CellSummaryPanel } from "./ui/CellSummaryPanel"; -import { MapControls } from "./ui/MapControls"; /** - * 지도 홈 오버레이(`/`) — 지속 셸(MapShell)이 렌더한 지도 위에 얹는 홈 전용 오버레이. - * 검색바·요약 패널·우하단 컨트롤 조립(얇은 뷰). 지도 명령은 셸 API(useMapShell)로 받는다. + * 홈 패널(`/`) — 지속 셸(MapShell)이 렌더한 지도 위에 얹는 388px 좌측 사이드바. + * 검색바(탐색 검색으로 가는 트리거) + 현재 뷰포트 요약(CellSummaryPanel)으로 구성된다. + * 다른 섹션과 동일하게 셸의 접기 핸들로 접어 지도를 넓게 볼 수 있다. 지도 컨트롤은 셸이 소유한다. */ export const MapHomePage = () => { const navigate = useNavigate(); - const { moveTo, zoomIn, zoomOut, locate } = useMapShell(); + const { moveTo } = useMapShell(); return ( -
-
- {/* 홈 검색바는 탐색 검색 패널로 가는 트리거 — 입력은 검색 패널에서 */} - navigate(ROUTES.explore, { state: { openSearch: true } })} - onFocus={() => navigate(ROUTES.explore, { state: { openSearch: true } })} - /> - navigate(ROUTES.explore)} - onCellSelect={moveTo} - /> -
- -
- navigate(ROUTES.upload)} - onLocate={locate} - onZoomIn={zoomIn} - onZoomOut={zoomOut} - /> -
-
+ ); }; diff --git a/apps/web/src/widgets/map-shell/MapShell.tsx b/apps/web/src/widgets/map-shell/MapShell.tsx index a4a6820e..b6c178b9 100644 --- a/apps/web/src/widgets/map-shell/MapShell.tsx +++ b/apps/web/src/widgets/map-shell/MapShell.tsx @@ -1,9 +1,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Outlet } from "react-router-dom"; +import { Outlet, useNavigate } from "react-router-dom"; +import { ROUTES } from "@/app/routes"; import type { LatLng } from "@/entities/cell"; import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { MapCanvas, type MapCanvasHandle } from "@/pages/map-home/ui/MapCanvas"; +import { MapControls } from "@/pages/map-home/ui/MapControls"; import { SEOUL_CITY_HALL, getCurrentPosition } from "@/shared/geolocation"; +import { SidebarCollapseHandle } from "./SidebarCollapseHandle"; +import { useSidebarStore } from "./sidebar-store"; import type { MapShellContext } from "./use-map-shell"; /** @@ -12,7 +16,10 @@ import type { MapShellContext } from "./use-map-shell"; * 지도 SDK import는 MapCanvas 경계 안에만 두고, 셸은 배치와 명령 주입만 담당한다. */ export const MapShell = () => { + const navigate = useNavigate(); const setViewport = useViewportStore((s) => s.setViewport); + const collapsed = useSidebarStore((s) => s.collapsed); + const setCollapsed = useSidebarStore((s) => s.setCollapsed); const mapRef = useRef(null); const [initialCenter, setInitialCenter] = useState(SEOUL_CITY_HALL); @@ -49,7 +56,27 @@ export const MapShell = () => { />
- + {/* 접힘 시 패널을 숨기되(display:none) 언마운트하지 않아 검색·필터 상태가 유지된다 */} +
+ +
+ + + + {/* 지도 컨트롤은 어떤 섹션에서도 항상 지도 위에 유지된다 */} +
+
+ { + setCollapsed(false); + navigate(ROUTES.upload); + }} + onLocate={context.locate} + onZoomIn={context.zoomIn} + onZoomOut={context.zoomOut} + /> +
+
); }; diff --git a/apps/web/src/widgets/map-shell/SidebarCollapseHandle.tsx b/apps/web/src/widgets/map-shell/SidebarCollapseHandle.tsx new file mode 100644 index 00000000..aad5308f --- /dev/null +++ b/apps/web/src/widgets/map-shell/SidebarCollapseHandle.tsx @@ -0,0 +1,30 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useSidebarStore } from "./sidebar-store"; + +/** + * 셸 레벨 접기/펼치기 핸들 — 어떤 섹션 패널에도 공통으로 붙는다. + * 펼침: 패널 우측 가장자리(388px)에서 ‹ 로 접기. 접힘: 레일 옆(0px)에서 › 로 펼치기. + * 네비 아이콘 재클릭과 동일한 collapsed 토글을 조작한다. + */ +export const SidebarCollapseHandle = () => { + const collapsed = useSidebarStore((s) => s.collapsed); + const toggle = useSidebarStore((s) => s.toggle); + + return ( + + ); +}; diff --git a/apps/web/src/widgets/map-shell/sidebar-store.ts b/apps/web/src/widgets/map-shell/sidebar-store.ts new file mode 100644 index 00000000..4ec6b9bb --- /dev/null +++ b/apps/web/src/widgets/map-shell/sidebar-store.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +interface SidebarState { + /** 사이드바 패널 접힘 여부 — true면 패널을 숨기고 지도를 넓게 보여준다(라우트와 무관) */ + collapsed: boolean; + setCollapsed: (collapsed: boolean) => void; + toggle: () => void; +} + +/** + * 사이드바 접힘 상태 — 구글맵식으로, 어느 네비 섹션에 있든 패널을 접어 지도 전체를 + * 볼 수 있게 하는 전역 UI 플래그. 라우트(활성 탭)와 분리해 두므로 접어도 탭은 유지된다. + */ +export const useSidebarStore = create((set) => ({ + collapsed: false, + setCollapsed: (collapsed) => set({ collapsed }), + toggle: () => set((s) => ({ collapsed: !s.collapsed })), +})); diff --git a/apps/web/src/widgets/section-panel/SectionPanel.tsx b/apps/web/src/widgets/section-panel/SectionPanel.tsx index 3403b194..39a15a2d 100644 --- a/apps/web/src/widgets/section-panel/SectionPanel.tsx +++ b/apps/web/src/widgets/section-panel/SectionPanel.tsx @@ -1,7 +1,4 @@ import type { ReactNode } from "react"; -import { useNavigate } from "react-router-dom"; -import { ROUTES } from "@/app/routes"; -import { SidebarCloseHandle } from "./SidebarCloseHandle"; interface SectionPanelProps { title: string; @@ -10,24 +7,19 @@ interface SectionPanelProps { /** * 네비 섹션 공통 사이드바 패널 — 지속 지도 셸(MapShell) 위에 얹히는 388px 좌측 오버레이. - * 헤더(제목) + 본문 + 우측 접기 핸들(닫으면 홈/지도로 복귀)로 구성되며, 탐색 패널과 폭·위치를 맞춘다. + * 헤더(제목) + 본문으로 구성되며, 탐색·홈 패널과 폭·위치를 맞춘다. 접기/펼치기는 셸의 + * 공통 핸들(SidebarCollapseHandle)이 담당하므로 패널마다 닫기 컨트롤을 두지 않는다. * 업로드·도감·프로필 등 아직 전용 화면이 없는 섹션이 이 래퍼로 사이드바를 얻는다. */ -export const SectionPanel = ({ title, children }: SectionPanelProps) => { - const navigate = useNavigate(); - return ( - - ); -}; +export const SectionPanel = ({ title, children }: SectionPanelProps) => ( + +); diff --git a/apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx b/apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx deleted file mode 100644 index 89fb192d..00000000 --- a/apps/web/src/widgets/section-panel/SidebarCloseHandle.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ChevronLeft } from "lucide-react"; - -interface SidebarCloseHandleProps { - /** 패널 닫기 — 지도(홈)를 넓게 보여준다 */ - onClose: () => void; -} - -/** - * 사이드바 우측 가장자리에 붙는 접기 핸들 — 클릭 시 패널을 닫아 지도를 넓게 보여준다. - * 네비 아이콘을 다시 눌러도 같은 닫기 동작을 한다(SideRailNav의 활성 아이콘 토글). - */ -export const SidebarCloseHandle = ({ onClose }: SidebarCloseHandleProps) => ( - -); diff --git a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx index 23a0d452..577414c4 100644 --- a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx +++ b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx @@ -2,6 +2,7 @@ import { Compass, Home, LayoutGrid, MapPin, Upload, User } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import { SideRail, type SideRailItem } from "@fillmap/ui-web"; import { ROUTES, getActiveNavKey, isNavKey, type NavKey } from "@/app/routes"; +import { useSidebarStore } from "@/widgets/map-shell/sidebar-store"; const items: (SideRailItem & { key: NavKey })[] = [ { key: "home", label: "홈", icon: }, @@ -11,10 +12,15 @@ const items: (SideRailItem & { key: NavKey })[] = [ { key: "profile", label: "프로필", icon: }, ]; -/** SideRail(ui-web)에 라우터를 연결한 조립 위젯 — 경로 기준 활성 표시 + 클릭 시 이동 */ +/** + * SideRail(ui-web)에 라우터를 연결한 조립 위젯 — 경로 기준 활성 표시 + 클릭 시 이동. + * 활성 탭 아이콘을 다시 누르면 사이드바를 접고(지도 전체), 다른 탭은 이동하며 펼친다(구글맵식). + */ export const SideRailNav = () => { const { pathname } = useLocation(); const navigate = useNavigate(); + const setCollapsed = useSidebarStore((s) => s.setCollapsed); + const toggle = useSidebarStore((s) => s.toggle); return ( { activeKey={getActiveNavKey(pathname)} onSelect={(key) => { if (!isNavKey(key)) return; - // 활성 섹션 아이콘을 다시 누르면 사이드바를 닫고 홈(지도)으로 복귀 — 열고 닫기 토글 - if (key !== "home" && key === getActiveNavKey(pathname)) { - navigate(ROUTES.home); + // 활성 탭 재클릭 → 접기/펼치기 토글, 다른 탭 → 이동하며 펼침 + if (key === getActiveNavKey(pathname)) { + toggle(); return; } + setCollapsed(false); navigate(ROUTES[key]); }} /> From 6685f83dc72102e86cd5f60fdd32b61a067da708 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 18 Jul 2026 13:40:20 +0900 Subject: [PATCH 031/281] =?UTF-8?q?MSG-114=20refactor:=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=9D=84=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=ED=83=90=EC=83=89=EC=9D=80=20=EC=A0=95=EB=A0=AC+=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explore/ui/RecentSearchRow.tsx | 6 +- .../explore/ui/RegionRow.tsx | 5 +- .../web/src/features/explore/ui/SearchBox.tsx | 141 ++++++++++++++++ apps/web/src/pages/explore/ExplorePanel.tsx | 104 ++++++------ apps/web/src/pages/explore/ui/SearchPanel.tsx | 155 ------------------ apps/web/src/pages/map-home/MapHomePage.tsx | 11 +- 6 files changed, 199 insertions(+), 223 deletions(-) rename apps/web/src/{pages => features}/explore/ui/RecentSearchRow.tsx (88%) rename apps/web/src/{pages => features}/explore/ui/RegionRow.tsx (83%) create mode 100644 apps/web/src/features/explore/ui/SearchBox.tsx delete mode 100644 apps/web/src/pages/explore/ui/SearchPanel.tsx diff --git a/apps/web/src/pages/explore/ui/RecentSearchRow.tsx b/apps/web/src/features/explore/ui/RecentSearchRow.tsx similarity index 88% rename from apps/web/src/pages/explore/ui/RecentSearchRow.tsx rename to apps/web/src/features/explore/ui/RecentSearchRow.tsx index ac2d470e..1ea3b1a3 100644 --- a/apps/web/src/pages/explore/ui/RecentSearchRow.tsx +++ b/apps/web/src/features/explore/ui/RecentSearchRow.tsx @@ -3,14 +3,14 @@ import { Clock, X } from "lucide-react"; interface RecentSearchRowProps { /** 최근 검색어 */ term: string; - /** 검색어 클릭 시 그 검색어로 필터 적용 (AC 6) */ + /** 검색어 클릭 시 그 검색어로 필터 적용 */ onSelect: (term: string) => void; - /** ✕ 클릭 시 해당 항목만 제거 (AC 7) */ + /** ✕ 클릭 시 해당 항목만 제거 */ onRemove: (term: string) => void; } /** - * 최근 검색 행 — 원형 아이콘 + 검색어 + 개별 삭제(✕). (AC 3/6/7) + * 최근 검색 행 — 원형 아이콘 + 검색어 + 개별 삭제(✕). * 검색(행 클릭)과 삭제(✕)를 형제 버튼으로 분리해 이벤트 버블링 충돌을 피한다. */ export const RecentSearchRow = ({ diff --git a/apps/web/src/pages/explore/ui/RegionRow.tsx b/apps/web/src/features/explore/ui/RegionRow.tsx similarity index 83% rename from apps/web/src/pages/explore/ui/RegionRow.tsx rename to apps/web/src/features/explore/ui/RegionRow.tsx index e8bf17ff..cee7e2f0 100644 --- a/apps/web/src/pages/explore/ui/RegionRow.tsx +++ b/apps/web/src/features/explore/ui/RegionRow.tsx @@ -5,13 +5,12 @@ interface RegionRowProps { name: string; /** 격자 수 (목값) */ count: number; - /** 행 클릭 시 해당 지역 필터 적용 (AC 5) */ + /** 행 클릭 시 해당 지역 필터 적용 */ onSelect: (name: string) => void; } /** - * 전체 지역 행 — 구 이름 + "격자 N" 개수 + 오른쪽 화살표(›). (AC 4) - * 이 화면 전용 조합이라 로컬로 둔다(승격 후보 아님). + * 전체 지역 행 — 구 이름 + "격자 N" 개수 + 오른쪽 화살표(›). */ export const RegionRow = ({ name, count, onSelect }: RegionRowProps) => ( + ))} +
+ )} + + + {/* 최근 검색 */} +
+
+

최근 검색

+ {!searchesEmpty && ( + + )} +
+ {searchesEmpty ? ( +

+ 최근 검색 기록이 없어요. +

+ ) : ( +
+ {recentSearches.map((term) => ( + + ))} +
+ )} +
+ + {/* 전체 지역 */} +
+

전체 지역

+
+ {regions.map((region) => ( + + ))} +
+
+
+ )} +
+ ); +}; diff --git a/apps/web/src/pages/explore/ExplorePanel.tsx b/apps/web/src/pages/explore/ExplorePanel.tsx index 4be0a519..b54a0a58 100644 --- a/apps/web/src/pages/explore/ExplorePanel.tsx +++ b/apps/web/src/pages/explore/ExplorePanel.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState } from "react"; import { useLocation } from "react-router-dom"; -import { SearchBar } from "@fillmap/ui-web"; import type { Bounds, Cell, LatLng } from "@/entities/cell"; import { selectExploreCells, @@ -15,79 +14,72 @@ import { useCellsQuery } from "@/features/map-home/model/use-cells-query"; import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { useMapShell } from "@/widgets/map-shell/use-map-shell"; import { ExploreCellCard } from "./ui/ExploreCellCard"; -import { SearchPanel } from "./ui/SearchPanel"; import { SortChip } from "./ui/SortChip"; -/** S4 지역명 — 뷰포트→행정구역명 변환은 범위 밖, 고정 목값 표시(D4) */ +/** 뷰포트→행정구역명 변환은 범위 밖, 고정 목값 표시 */ const REGION_LABEL = "서울 마포구 격자"; +interface ExploreNavState { + /** 검색으로 진입 시 적용할 검색어 */ + searchQuery?: string; + /** 지역 선택으로 진입 시 적용할 구 */ + searchRegion?: string; +} + /** - * 탐색 패널(`/explore`) — 지속 셸(MapShell)이 렌더한 지도 위에 얹는 388px 오버레이(S1). - * 검색창(S2)+정렬 칩(S3)+뷰포트 요약 헤더(S4)+2열 카드 그리드(S5)+빈 상태(S9)로 구성된다. - * 검색·정렬 상태는 로컬로 관리하고, 목록 파생은 순수 셀렉터(selectExploreCells)에 위임한다. + * 탐색 패널(`/explore`) — 지속 셸(MapShell)이 렌더한 지도 위에 얹는 388px 오버레이. + * 정렬 칩(인기순/최신순) + 뷰포트 요약 헤더 + 2열 카드 그리드로 결과를 보여준다. + * 검색 입력은 SearchBox(드롭다운)가 담당하고, 이 패널은 진입 시 넘어온 필터를 반영해 조회만 한다. */ export const ExplorePanel = () => { const { moveTo } = useMapShell(); const bounds = useViewportStore((s) => s.bounds); const { data, isLoading, isError, refetch } = useCellsQuery(); - // 검색어·선택 지역은 공유 필터 스토어로 승격(MSG-114) — 정렬은 이 화면 로컬 유지(MSG-113) const query = useExploreFilterStore((s) => s.query); const selectedRegion = useExploreFilterStore((s) => s.selectedRegion); const clearFilters = useExploreFilterStore((s) => s.clearFilters); + const applySearch = useExploreFilterStore((s) => s.applySearch); + const selectRegion = useExploreFilterStore((s) => s.selectRegion); const [order, setOrder] = useState("popular"); - // 홈 검색바에서 진입하면(openSearch state) 검색 패널이 바로 열린 채 시작한다 const location = useLocation(); - const [searchOpen, setSearchOpen] = useState( - () => Boolean((location.state as { openSearch?: boolean } | null)?.openSearch), - ); - // 탐색 진입 시마다 이전 검색어·지역 필터 초기화 — 최근 기록은 유지 - useEffect(() => clearFilters(), [clearFilters]); + // 진입 시 필터 초기화 후, 검색/지역으로 넘어왔으면 그 필터만 적용한다. + // (네비 아이콘·"전체 보기"로 진입하면 state가 없어 전체 조회) — 마운트 1회. + useEffect(() => { + clearFilters(); + const nav = location.state as ExploreNavState | null; + if (nav?.searchQuery) applySearch(nav.searchQuery); + else if (nav?.searchRegion) selectRegion(nav.searchRegion); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( ); }; @@ -158,7 +150,11 @@ const ExploreBody = ({ <>
- {district ? `서울 ${district} 격자` : REGION_LABEL} + {query.trim() + ? `'${query.trim()}' 검색 결과` + : district + ? `서울 ${district} 격자` + : REGION_LABEL} {cellCount}개
diff --git a/apps/web/src/pages/explore/ui/SearchPanel.tsx b/apps/web/src/pages/explore/ui/SearchPanel.tsx deleted file mode 100644 index ac09dcb8..00000000 --- a/apps/web/src/pages/explore/ui/SearchPanel.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useMemo, useState } from "react"; -import { ChevronLeft, Search } from "lucide-react"; -import { selectRegions } from "@/entities/region"; -import { useExploreFilterStore } from "@/features/explore/model/explore-filter-store"; -import { isHistoryEmpty } from "@/features/explore/model/recent-history"; -import { RecentSearchRow } from "./RecentSearchRow"; -import { RegionRow } from "./RegionRow"; -import { SortChip } from "./SortChip"; - -interface SearchPanelProps { - /** 검색 모드 닫기(뒤로가기·필터 선택 후 복귀) */ - onClose: () => void; -} - -/** - * 검색 모드 인라인 콘텐츠 — 탐색 패널(aside) 안에서 검색바를 그 자리에 유지한 채 - * 아래 영역만 최근 방문·최근 검색·전체 지역으로 바꾼다(네이버/카카오 지도식 인라인 전개). - * 별도 오버레이·백드롭 없이 aside의 flex column 안에 그대로 들어간다. - * 검색 필은 탐색 패널 SearchBar와 같은 자리·형태(배경색만 surface로 구분)이며 좌측에 - * 뒤로가기(‹)를 둔다. 타이핑 후 엔터/검색 아이콘으로 커밋하면 필터가 적용된 목록으로 복귀한다(D2). - */ -export const SearchPanel = ({ onClose }: SearchPanelProps) => { - const recentVisits = useExploreFilterStore((s) => s.recentVisits); - const recentSearches = useExploreFilterStore((s) => s.recentSearches); - const applySearch = useExploreFilterStore((s) => s.applySearch); - const selectRegion = useExploreFilterStore((s) => s.selectRegion); - const removeRecentSearch = useExploreFilterStore((s) => s.removeRecentSearch); - const clearRecentSearches = useExploreFilterStore( - (s) => s.clearRecentSearches, - ); - const regions = useMemo(() => selectRegions(), []); - const [input, setInput] = useState(""); - - const commitSearch = (term: string) => { - if (!term.trim()) return; - applySearch(term); - onClose(); - }; - - const chooseRegion = (name: string) => { - selectRegion(name); - onClose(); - }; - - const visitsEmpty = isHistoryEmpty(recentVisits); - const searchesEmpty = isHistoryEmpty(recentSearches); - - return ( - <> - {/* 검색 필 — 탐색 패널 SearchBar와 같은 자리(p-md)·형태, 배경색만 surface로 구분하고 - 좌측에 뒤로가기 버튼을 추가한다(전환 시 위치·모양이 튀지 않도록). */} -
-
- - setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commitSearch(input); - }} - placeholder="장소, 격자, 영상 검색" - className="min-w-0 flex-1 bg-transparent text-fm-title font-normal leading-none text-foreground outline-none placeholder:text-foreground-muted" - /> - -
-
- -
- {/* 최근 방문 */} -
-

최근 방문

- {visitsEmpty ? ( -

- 최근 방문한 지역이 없어요. -

- ) : ( -
- {recentVisits.map((visit) => ( - chooseRegion(visit)} - /> - ))} -
- )} -
- - {/* 최근 검색 */} -
-
-

최근 검색

- {!searchesEmpty && ( - - )} -
- {searchesEmpty ? ( -

- 최근 검색 기록이 없어요. -

- ) : ( -
- {recentSearches.map((term) => ( - - ))} -
- )} -
- - {/* 전체 지역 */} -
-

전체 지역

-
- {regions.map((region) => ( - - ))} -
-
-
- - ); -}; diff --git a/apps/web/src/pages/map-home/MapHomePage.tsx b/apps/web/src/pages/map-home/MapHomePage.tsx index a55fb0e1..3d92f662 100644 --- a/apps/web/src/pages/map-home/MapHomePage.tsx +++ b/apps/web/src/pages/map-home/MapHomePage.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { SearchBar } from "@fillmap/ui-web"; import { ROUTES } from "@/app/routes"; +import { SearchBox } from "@/features/explore/ui/SearchBox"; import { useMapShell } from "@/widgets/map-shell/use-map-shell"; import { CellSummaryPanel } from "./ui/CellSummaryPanel"; @@ -15,13 +15,8 @@ export const MapHomePage = () => { return (
); }; @@ -76,7 +97,10 @@ interface ExploreBodyProps { order: SortOrder; /** 선택된 지역 필터 — null이면 지역 필터 미적용 (MSG-114) */ district: string | null; + /** 상세가 열린 격자 id — 카드 선택 강조 (MSG-115) */ + selectedCellId: string | null; onCellSelect: (center: LatLng) => void; + onCellDetailSelect: (cell: Cell) => void; } /** 요약 헤더 + 카드 그리드 / 로딩 · 에러 · 빈 상태 분기 */ @@ -89,7 +113,9 @@ const ExploreBody = ({ query, order, district, + selectedCellId, onCellSelect, + onCellDetailSelect, }: ExploreBodyProps) => { // early return(isError·isLoading) 아래에 두면 렌더마다 훅 호출 여부가 달라져 // Rules of Hooks를 어기므로, 분기 위에서 무조건 호출하고 null 처리는 내부에서 한다. @@ -158,7 +184,9 @@ const ExploreBody = ({ ))}
diff --git a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx new file mode 100644 index 00000000..32b6fb8a --- /dev/null +++ b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx @@ -0,0 +1,136 @@ +import { MoreHorizontal, Play, Share2, X } from "lucide-react"; +import { Button, cn, VideoRow } from "@fillmap/ui-web"; +import type { Cell } from "@/entities/cell"; +import { + formatRelativeTime, + formatViewCount, +} from "@/features/explore/model/cell-detail"; +import { useCellDetailStore } from "@/features/explore/model/cell-detail-store"; +import { formatDuration } from "@/features/explore/model/explore-cells"; + +interface CellDetailSheetProps { + cell: Cell; + className?: string; +} + +/** + * 격자 상세 시트 — 목록 오른쪽 컬럼에 나란히 붙는 신규 컨테이너(모달·전체 전환 아님, AC 1). + * 대표 영상 플레이어(자리) / 격자 메타 / 통계 3항목 / 액션 버튼(자리) / "이 격자의 영상" 리스트로 구성. + * BottomSheet(하단 도킹)와 역할이 달라 재사용하지 않고 별도 셸로 둔다(스펙 명시). + * 대표 영상·닫기는 cell-detail-store를 구독한다. 실제 재생/업로드/공유는 [제외 범위]로 no-op. + */ +export const CellDetailSheet = ({ cell, className }: CellDetailSheetProps) => { + const activeVideoId = useCellDetailStore((s) => s.activeVideoId); + const selectVideo = useCellDetailStore((s) => s.selectVideo); + const close = useCellDetailStore((s) => s.close); + + const activeVideo = + cell.videos.find((v) => v.id === activeVideoId) ?? cell.videos[0]; + const duration = formatDuration(activeVideo?.durationSec); + + return ( +
+
+ {/* 대표 영상 플레이어 영역 (자리) — 재생 버튼 + 길이 (AC 10) */} +
+ + + + {duration && ( + + {duration} + + )} + +
+ + {/* 격자명 + 위치 · 최근 업로드 (AC 11) */} +
+

{cell.label}

+

+ {cell.location} · 최근 업로드 {formatRelativeTime(cell.recentUploadedAt)} +

+
+ + {/* 통계 — 담수율 / 영상 수 / 조회수 (AC 12) */} +
+ + + +
+ + {/* 액션 버튼 (자리, no-op) — 업로드 / 공유 / 더보기 (AC 15) */} +
+
+
+ + {/* 이 격자의 영상 (AC 16·17·18) */} +
+

이 격자의 영상

+
    + {cell.videos.map((videoItem) => ( +
  • + selectVideo(videoItem.id)} + className={cn( + "rounded-sm", + videoItem.id === activeVideo?.id && "bg-surface", + )} + /> +
  • + ))} +
+
+
+ ); +}; + +const Stat = ({ value, label }: { value: string; label: string }) => ( +
+
{label}
+
{value}
+
+); + +const IconButton = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + +); diff --git a/apps/web/src/pages/explore/ui/ExploreCellCard.tsx b/apps/web/src/pages/explore/ui/ExploreCellCard.tsx index d5f8f4d8..ffa82c44 100644 --- a/apps/web/src/pages/explore/ui/ExploreCellCard.tsx +++ b/apps/web/src/pages/explore/ui/ExploreCellCard.tsx @@ -1,26 +1,47 @@ import { Play } from "lucide-react"; +import { cn } from "@fillmap/ui-web"; import type { Cell, LatLng } from "@/entities/cell"; import { formatDuration } from "@/features/explore/model/explore-cells"; interface ExploreCellCardProps { cell: Cell; - /** 카드 클릭 시 지도를 해당 격자 중심으로 이동(S7) */ + /** 카드 클릭 시 지도를 해당 격자 중심으로 이동(S7) — 유지 */ onSelect: (center: LatLng) => void; + /** 카드 클릭 시 상세 시트를 연다(MSG-115) */ + onDetailSelect: (cell: Cell) => void; + /** 상세가 열린 격자인지 — 선택 강조 (AC 7 시각 확인 보조) */ + selected?: boolean; } /** * 탐색 격자 카드 — 썸네일(공용 placeholder)+재생 아이콘 오버레이+영상 길이 배지, * 동네명+코드(S5), "N개 영상"을 표시한다. durationSec이 없으면 배지 미표시(S6). + * 클릭 시 지도 이동(S7)과 상세 시트 열기(MSG-115)를 함께 트리거한다. + * videoCount === 0인 격자는 비활성(disabled·흐림) 처리되어 클릭되지 않는다(AC 3). * 2열 그리드 셀로 배치되며 너비는 부모 그리드가 결정한다. */ -export const ExploreCellCard = ({ cell, onSelect }: ExploreCellCardProps) => { +export const ExploreCellCard = ({ + cell, + onSelect, + onDetailSelect, + selected, +}: ExploreCellCardProps) => { const duration = formatDuration(cell.durationSec); + const disabled = cell.videoCount === 0; return (
-
+
{displayCells.length === 0 ? (

{query.trim() From 5bae8b4eb81bb9b9cda9c54cdcab0b22e3e17190 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 15:16:35 +0900 Subject: [PATCH 039/281] =?UTF-8?q?MSG-115=20fix:=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=EC=9E=AC=EC=84=A0=ED=83=9D=20=EC=8B=9C=20?= =?UTF-8?q?activeVideoId=EA=B0=80=20=EB=A6=AC=EC=85=8B=EB=90=98=EB=8A=94?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/explore/model/cell-detail-store.test.ts | 8 ++++++++ apps/web/src/features/explore/model/cell-detail-store.ts | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/explore/model/cell-detail-store.test.ts b/apps/web/src/features/explore/model/cell-detail-store.test.ts index 4e23f271..c05aa0a8 100644 --- a/apps/web/src/features/explore/model/cell-detail-store.test.ts +++ b/apps/web/src/features/explore/model/cell-detail-store.test.ts @@ -72,6 +72,14 @@ describe("cell-detail-store 선택/영상 액션", () => { state().select(cellB); expect(state().activeVideoId).toBe("B-v1"); }); + + it("이미 열린 같은 격자를 재선택하면 no-op이다 — 선택해둔 영상이 대표 영상으로 리셋되지 않는다", () => { + state().select(cellA); + state().selectVideo("A-v3"); + state().select(cellA); + expect(state().activeVideoId).toBe("A-v3"); + expect(state().selectedCellId).toBe("A"); + }); }); describe("필터 스토어 변경과 선택 상태의 독립성 (AC 8)", () => { diff --git a/apps/web/src/features/explore/model/cell-detail-store.ts b/apps/web/src/features/explore/model/cell-detail-store.ts index c1b35597..781b2852 100644 --- a/apps/web/src/features/explore/model/cell-detail-store.ts +++ b/apps/web/src/features/explore/model/cell-detail-store.ts @@ -6,7 +6,7 @@ interface CellDetailState { selectedCellId: string | null; /** 상단 대표 영상 영역에 표시 중인 영상 id — 선택 시 대표 영상(videos[0])으로 초기화 */ activeVideoId: string | null; - /** 격자를 선택해 상세 시트를 연다. videoCount === 0이면 no-op (AC 2). activeVideoId는 대표 영상으로 리셋 (AC 20). */ + /** 격자를 선택해 상세 시트를 연다. videoCount === 0이면 no-op (AC 2). 다른 격자로 전환할 때만 activeVideoId를 대표 영상으로 리셋한다 (AC 20) — 이미 열린 같은 격자를 재클릭하면 no-op이라 선택해둔 영상이 유지된다. */ select: (cell: Cell) => void; /** 리스트 영상을 대표 영상 영역에 반영한다 — 실제 재생 트리거 없음 (AC 17). */ selectVideo: (videoId: string) => void; @@ -24,7 +24,10 @@ export const useCellDetailStore = create((set) => ({ activeVideoId: null, select: (cell) => { if (cell.videoCount === 0) return; - set({ selectedCellId: cell.id, activeVideoId: cell.videos[0]?.id ?? null }); + set((state) => { + if (state.selectedCellId === cell.id) return state; + return { selectedCellId: cell.id, activeVideoId: cell.videos[0]?.id ?? null }; + }); }, selectVideo: (videoId) => set({ activeVideoId: videoId }), close: () => set({ selectedCellId: null, activeVideoId: null }), From 5827182bd6983dd4a78fafa78d924ea450dc8dd0 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 15:19:57 +0900 Subject: [PATCH 040/281] =?UTF-8?q?MSG-115=20fix:=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EC=B6=95=EC=95=BD=20=EC=8B=9C=20999500~999999=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20"1000K"=20=EC=98=A4=ED=91=9C=EA=B8=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/features/explore/model/cell-detail.test.ts | 8 ++++++++ apps/web/src/features/explore/model/cell-detail.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/explore/model/cell-detail.test.ts b/apps/web/src/features/explore/model/cell-detail.test.ts index 9fe4e6be..cba45bd3 100644 --- a/apps/web/src/features/explore/model/cell-detail.test.ts +++ b/apps/web/src/features/explore/model/cell-detail.test.ts @@ -17,6 +17,14 @@ describe("formatViewCount — 조회수 축약 (AC 13)", () => { it("0을 그대로 표시한다", () => { expect(formatViewCount(0)).toBe("0"); }); + + it("반올림 시 1000K로 넘어가는 경계값은 M 단위로 표기한다 (999999 → '1M', '1000K' 아님)", () => { + expect(formatViewCount(999_999)).toBe("1M"); + }); + + it("100만은 '1M'로 표시한다", () => { + expect(formatViewCount(1_000_000)).toBe("1M"); + }); }); describe("formatRelativeTime — 상대 시간 (AC 14)", () => { diff --git a/apps/web/src/features/explore/model/cell-detail.ts b/apps/web/src/features/explore/model/cell-detail.ts index f67189cc..f90dd429 100644 --- a/apps/web/src/features/explore/model/cell-detail.ts +++ b/apps/web/src/features/explore/model/cell-detail.ts @@ -16,9 +16,12 @@ const compact = (value: number): string => { * - 천 단위: 소수 첫째 자리 K (1400 → "1.4K") * - 만 단위 이상: 소수 없이 K (12000 → "12K") */ +/** 반올림 시 1000K로 넘어가는 경계값 — 이 이상은 M 단위로 표기해야 "1000K" 오표기가 안 생긴다 */ +const K_TO_M_THRESHOLD = 999_500; + export const formatViewCount = (count: number): string => { if (count < 1_000) return String(count); - if (count < 1_000_000) return `${compact(count / 1_000)}K`; + if (count < K_TO_M_THRESHOLD) return `${compact(count / 1_000)}K`; return `${compact(count / 1_000_000)}M`; }; From 9d72b652a6d50d2464bf571c5de4ec7553b54caa Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 15:22:26 +0900 Subject: [PATCH 041/281] =?UTF-8?q?MSG-115=20fix:=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=EC=97=90=20Escape=20=ED=82=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=AB=EA=B8=B0=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ui/CellDetailSheet.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx index 32b6fb8a..8c2b6b24 100644 --- a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx +++ b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { MoreHorizontal, Play, Share2, X } from "lucide-react"; import { Button, cn, VideoRow } from "@fillmap/ui-web"; import type { Cell } from "@/entities/cell"; @@ -28,6 +29,15 @@ export const CellDetailSheet = ({ cell, className }: CellDetailSheetProps) => { cell.videos.find((v) => v.id === activeVideoId) ?? cell.videos[0]; const duration = formatDuration(activeVideo?.durationSec); + // 우측 컬럼이 목록을 상당 부분 덮으므로 Escape로도 닫을 수 있게 한다 (키보드 접근성) + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [close]); + return (

Date: Mon, 20 Jul 2026 16:32:01 +0900 Subject: [PATCH 042/281] =?UTF-8?q?MSG-116=20feat:=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/explore/model/report.test.ts | 65 +++++++++++++++ apps/web/src/features/explore/model/report.ts | 33 ++++++++ .../src/pages/explore/ui/CellDetailSheet.tsx | 20 +++-- .../web/src/pages/explore/ui/CellMoreMenu.tsx | 40 +++++++++ .../web/src/pages/explore/ui/ReportDialog.tsx | 83 +++++++++++++++++++ .../pages/explore/ui/ReportReasonSelect.tsx | 53 ++++++++++++ docs/decisions/DECISIONS.md | 2 + packages/ui-web/src/modal-card.stories.tsx | 14 ++++ packages/ui-web/src/modal-card.tsx | 6 +- 9 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/features/explore/model/report.test.ts create mode 100644 apps/web/src/features/explore/model/report.ts create mode 100644 apps/web/src/pages/explore/ui/CellMoreMenu.tsx create mode 100644 apps/web/src/pages/explore/ui/ReportDialog.tsx create mode 100644 apps/web/src/pages/explore/ui/ReportReasonSelect.tsx diff --git a/apps/web/src/features/explore/model/report.test.ts b/apps/web/src/features/explore/model/report.test.ts new file mode 100644 index 00000000..65c461c1 --- /dev/null +++ b/apps/web/src/features/explore/model/report.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { canSubmitReport, REPORT_REASONS, submitReport } from "./report"; + +describe("REPORT_REASONS", () => { + // L1: 신고 사유 옵션은 정확히 3개이며 라벨이 확정 문구와 일치한다 + it("정확히 3개의 사유 옵션을 가진다", () => { + expect(REPORT_REASONS).toHaveLength(3); + }); + + it("각 옵션의 라벨이 기획에 확정된 문구와 일치한다", () => { + expect(REPORT_REASONS.map((r) => r.label)).toEqual([ + "부적절한 콘텐츠(선정성·폭력성 등)", + "사생활 침해(얼굴·번호판 등 노출)", + "스팸 또는 도배성 콘텐츠", + ]); + }); + + it("각 옵션의 id는 content / privacy / spam 이다", () => { + expect(REPORT_REASONS.map((r) => r.id)).toEqual([ + "content", + "privacy", + "spam", + ]); + }); +}); + +describe("canSubmitReport", () => { + // L2: 사유 미선택(null)이면 false + it("사유가 선택되지 않았으면(null) false를 반환한다", () => { + expect(canSubmitReport(null)).toBe(false); + }); + + // L2: 유효한 사유 id면 true + it("유효한 사유 id가 선택되면 true를 반환한다", () => { + for (const reason of REPORT_REASONS) { + expect(canSubmitReport(reason.id)).toBe(true); + } + }); + + // L3: 목록에 없는 id면 false + it("유효하지 않은(목록에 없는) 사유 id면 false를 반환한다", () => { + expect(canSubmitReport("unknown")).toBe(false); + expect(canSubmitReport("")).toBe(false); + }); +}); + +describe("submitReport", () => { + // L4: 목업 제출은 선택된 사유 id로 성공 결과를 반환한다 + it("선택된 사유 id를 받아 성공 결과를 반환한다", async () => { + await expect(submitReport("content")).resolves.toEqual({ ok: true }); + }); + + // L4: 서버/네트워크·플랫폼 API를 호출하지 않는다 + it("fetch 등 네트워크·플랫폼 API를 호출하지 않는다", async () => { + const fetchSpy = vi.fn(); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchSpy as unknown as typeof fetch; + try { + await submitReport("spam"); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/apps/web/src/features/explore/model/report.ts b/apps/web/src/features/explore/model/report.ts new file mode 100644 index 00000000..09cb4e69 --- /dev/null +++ b/apps/web/src/features/explore/model/report.ts @@ -0,0 +1,33 @@ +/** + * 영상 신고 도메인 로직 — 순수 함수/상수 + 제출 목업 (MSG-116 L1~L4). + * 플랫폼 API(window·fetch 등)를 참조하지 않는다 — RN 재사용 대상. + * 서버 연동은 이 티켓의 제외 범위이므로 submitReport는 지연 없이 성공 처리한다. + */ + +/** 신고 사유 옵션. id는 안정 키, label은 화면 노출 문구. [L1] */ +export const REPORT_REASONS = [ + { id: "content", label: "부적절한 콘텐츠(선정성·폭력성 등)" }, + { id: "privacy", label: "사생활 침해(얼굴·번호판 등 노출)" }, + { id: "spam", label: "스팸 또는 도배성 콘텐츠" }, +] as const; + +/** 신고 사유 id 유니온 */ +export type ReportReasonId = (typeof REPORT_REASONS)[number]["id"]; + +/** + * 신고 제출 가능 여부를 판정한다. [L2·L3] + * 사유가 선택되지 않았거나(null) 목록에 없는 id면 false, 유효한 id면 true. + */ +export const canSubmitReport = (reasonId: string | null): boolean => + reasonId !== null && REPORT_REASONS.some((r) => r.id === reasonId); + +/** + * 신고 제출(목업). [L4] + * 선택된 사유 id를 받아 로컬에서 성공 결과를 반환한다 — + * 서버/네트워크·플랫폼 API를 호출하지 않는다. + */ +export const submitReport = (reasonId: string): Promise<{ ok: true }> => { + // 서버 미연동 목업 — 실제 API 연동 시 reasonId를 전송한다. + void reasonId; + return Promise.resolve({ ok: true }); +}; diff --git a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx index 8c2b6b24..14b9c784 100644 --- a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx +++ b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { MoreHorizontal, Play, Share2, X } from "lucide-react"; import { Button, cn, VideoRow } from "@fillmap/ui-web"; import type { Cell } from "@/entities/cell"; @@ -8,6 +8,8 @@ import { } from "@/features/explore/model/cell-detail"; import { useCellDetailStore } from "@/features/explore/model/cell-detail-store"; import { formatDuration } from "@/features/explore/model/explore-cells"; +import { CellMoreMenu } from "./CellMoreMenu"; +import { ReportDialog } from "./ReportDialog"; interface CellDetailSheetProps { cell: Cell; @@ -25,6 +27,8 @@ export const CellDetailSheet = ({ cell, className }: CellDetailSheetProps) => { const selectVideo = useCellDetailStore((s) => s.selectVideo); const close = useCellDetailStore((s) => s.close); + const [reportOpen, setReportOpen] = useState(false); + const activeVideo = cell.videos.find((v) => v.id === activeVideoId) ?? cell.videos[0]; const duration = formatDuration(activeVideo?.durationSec); @@ -91,12 +95,16 @@ export const CellDetailSheet = ({ cell, className }: CellDetailSheetProps) => { - - - + setReportOpen(true)}> + + + +
+ + {/* 이 격자의 영상 (AC 16·17·18) */}

이 격자의 영상

@@ -132,14 +140,16 @@ const Stat = ({ value, label }: { value: string; label: string }) => ( const IconButton = ({ label, children, + ...props }: { label: string; children: React.ReactNode; -}) => ( +} & React.ComponentPropsWithRef<"button">) => ( diff --git a/apps/web/src/pages/explore/ui/CellMoreMenu.tsx b/apps/web/src/pages/explore/ui/CellMoreMenu.tsx new file mode 100644 index 00000000..e92d0f42 --- /dev/null +++ b/apps/web/src/pages/explore/ui/CellMoreMenu.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { DropdownMenu } from "radix-ui"; + +interface CellMoreMenuProps { + /** 트리거로 감쌀 더보기(⋯) 버튼 */ + children: ReactNode; + /** 신고하기 선택 시 호출 (모달 열기) */ + onReport: () => void; +} + +/** + * 격자 더보기(⋯) 드롭다운 메뉴 — Radix DropdownMenu 기반 페이지 로컬 구현. + * 항목: 수정하기 / 삭제하기(둘 다 no-op, 메뉴만 닫힘) / 신고하기(→ onReport). + * 바깥 클릭·Escape 닫힘, 포털 렌더는 Radix 기본 제공 — S1·S2·S3·S10. + */ +export const CellMoreMenu = ({ children, onReport }: CellMoreMenuProps) => ( + + {children} + + + + 수정하기 + + + 삭제하기 + + + 신고하기 + + + + +); diff --git a/apps/web/src/pages/explore/ui/ReportDialog.tsx b/apps/web/src/pages/explore/ui/ReportDialog.tsx new file mode 100644 index 00000000..f0297a22 --- /dev/null +++ b/apps/web/src/pages/explore/ui/ReportDialog.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "radix-ui"; +import { ModalCard, Toast } from "@fillmap/ui-web"; +import { + canSubmitReport, + submitReport, +} from "@/features/explore/model/report"; +import { ReportReasonSelect } from "./ReportReasonSelect"; + +interface ReportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const REPORT_GUIDE = + "이 영상을 신고하는 이유를 선택해주세요. 신고는 익명으로 처리됩니다."; +const TOAST_MESSAGE = "신고가 접수되었습니다."; +const TOAST_DURATION_MS = 3000; + +/** + * 영상 신고 모달 — Radix Dialog(오버레이·포털·포커스 트랩)로 ModalCard를 감싼다. + * 사유 선택 상태는 로컬 state이며 닫힐 때 초기화된다(S9). + * 취소/✕/바깥 클릭/Escape는 닫기만(제출 안 함) — S8. 제출 시 목업 호출 후 토스트 + 닫기 — S7. + * 토스트 호스트 인프라가 없어 로컬 setTimeout 자동 소멸로 처리한다(R2). + */ +export const ReportDialog = ({ open, onOpenChange }: ReportDialogProps) => { + const [reasonId, setReasonId] = useState(null); + const [toastVisible, setToastVisible] = useState(false); + + // 닫힐 때 선택 상태를 초기화해 다시 열면 제출 버튼이 비활성으로 돌아온다 (S9) + const handleOpenChange = (next: boolean) => { + if (!next) setReasonId(null); + onOpenChange(next); + }; + + const handleSubmit = async () => { + if (!canSubmitReport(reasonId)) return; + await submitReport(reasonId as string); + handleOpenChange(false); + setToastVisible(true); + }; + + // 토스트 자동 소멸 + useEffect(() => { + if (!toastVisible) return; + const timer = setTimeout(() => setToastVisible(false), TOAST_DURATION_MS); + return () => clearTimeout(timer); + }, [toastVisible]); + + return ( + <> + + + + + 영상 신고 + handleOpenChange(false)} + onConfirm={handleSubmit} + onClose={() => handleOpenChange(false)} + > + + + + + + + {toastVisible && ( +
+ +
+ )} + + ); +}; diff --git a/apps/web/src/pages/explore/ui/ReportReasonSelect.tsx b/apps/web/src/pages/explore/ui/ReportReasonSelect.tsx new file mode 100644 index 00000000..10821be5 --- /dev/null +++ b/apps/web/src/pages/explore/ui/ReportReasonSelect.tsx @@ -0,0 +1,53 @@ +import { Select } from "radix-ui"; +import { Check, ChevronDown } from "lucide-react"; +import { REPORT_REASONS } from "@/features/explore/model/report"; + +interface ReportReasonSelectProps { + /** 선택된 사유 id. 미선택이면 null */ + value: string | null; + onValueChange: (reasonId: string) => void; +} + +/** + * 신고 사유 선택 필드 — Radix Select 기반 페이지 로컬 구현 (MSG-116 S5·S6). + * 소비처가 이 화면 1곳뿐이라 ui-web 승격 대신 페이지 로컬로 둔다(스펙 트레이드오프 결정). + * 옵션 팝업은 Radix 기본 포털로 렌더되어 상세 시트의 overflow 클리핑을 회피한다(R3). + */ +export const ReportReasonSelect = ({ + value, + onValueChange, +}: ReportReasonSelectProps) => ( + + + + + + + + + + + {REPORT_REASONS.map((reason) => ( + + {reason.label} + + + + + ))} + + + + +); diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index 38a5bd3a..4a0f09e6 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -17,3 +17,5 @@ | 2026-07-18 | MSG-114 | 폐기(위 행): 검색이 드롭다운(`SearchBox`)으로 재구성되며 공용 `SearchBar`를 재사용하게 됨. `SearchBar`에 선택적 `onSearch`를 추가해 아이콘을 클릭 가능한 버튼으로 만들어 D2("아이콘 클릭 커밋")를 SearchBar 재사용으로 충족 | 리팩터로 로컬 input+버튼이 사라지며 아이콘 클릭 커밋이 누락됐다는 코드리뷰 지적을 반영. 별도 로컬 조합 대신 공용 컴포넌트를 확장하는 편이 재사용성·일관성에 유리하고, 장식용 사용처는 onSearch 미지정으로 하위호환 유지 | | 2026-07-20 | MSG-115 | 결정: 우측 컬럼 상세 시트를 공용 `BottomSheet` 재사용 대신 신규 `CellDetailSheet`로 구현 | `BottomSheet`는 하단 도킹 전용(rounded-t·shadow-sheet)이라 리스트 옆에 붙는 우측 컬럼 레이아웃과 구조가 안 맞음. 강제 재사용 시 불필요한 하단 전용 스타일을 오버라이드해야 해 오히려 결합도만 높아짐. `VideoRow`·`Button`은 그대로 재사용 | | 2026-07-20 | MSG-115 | 결정: 격자 카드 클릭이 기존 지도 이동(MSG-113 S7)과 신규 상세 시트 열기 두 역할을 겸함 | 티켓 문구가 지도 이동을 언급하지 않아 스펙 단계에서 사용자에게 확인 — 기존 동작 유지 + 상세 선택 추가로 확정. 별도 클릭 영역 분리는 과설계로 판단 | +| 2026-07-20 | MSG-116 | 결정: `ModalCard`(ui-web)에 `confirmDisabled?: boolean` 후위호환 prop 추가 | 신고 모달의 "사유 미선택 시 제출 비활성"(S6)을 충족하려면 확인 버튼 disabled가 필요한데 기존 `ModalCard`는 미지원. 로컬 재구현 대신 공통 컴포넌트를 확장해 다른 확인형 모달에도 재사용 가능하게 함 — 사용자 승인 후 진행 | +| 2026-07-20 | MSG-116 | 결정: 신고 사유 선택 UI(Radix `Select`)를 ui-web으로 승격하지 않고 페이지 로컬(`ReportReasonSelect.tsx`)로 구현 | 소비처가 이 화면 1곳뿐이라 디자인 시스템의 "재사용될 때 승격" 규칙에 미달 — 조기 추상화 대신 두 번째 소비처가 생기면 승격 | diff --git a/packages/ui-web/src/modal-card.stories.tsx b/packages/ui-web/src/modal-card.stories.tsx index d7333082..5edc5c95 100644 --- a/packages/ui-web/src/modal-card.stories.tsx +++ b/packages/ui-web/src/modal-card.stories.tsx @@ -27,3 +27,17 @@ export const Playground: Story = {
), }; + +/** confirmDisabled=true — 확인 버튼 비활성(클릭 차단 + 비활성 시각). */ +export const ConfirmDisabled: Story = { + args: { confirmDisabled: true }, + render: (args) => ( +
+ +
+ 콘텐츠 영역 +
+
+
+ ), +}; diff --git a/packages/ui-web/src/modal-card.tsx b/packages/ui-web/src/modal-card.tsx index e90d70aa..53bf6a61 100644 --- a/packages/ui-web/src/modal-card.tsx +++ b/packages/ui-web/src/modal-card.tsx @@ -9,6 +9,8 @@ interface ModalCardProps { children?: ReactNode; cancelText?: string; confirmText?: string; + /** true면 확인 버튼을 비활성(클릭 차단 + 비활성 시각)한다 */ + confirmDisabled?: boolean; onCancel?: () => void; onConfirm?: () => void; /** 지정하면 우측 상단 닫기 버튼 표시 */ @@ -29,6 +31,7 @@ export const ModalCard = ({ children, cancelText, confirmText, + confirmDisabled, onCancel, onConfirm, onClose, @@ -76,7 +79,8 @@ export const ModalCard = ({ From 27d1aa5e648621593a5e1de0239c1c003abf86d4 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 16:37:35 +0900 Subject: [PATCH 043/281] =?UTF-8?q?MSG-116=20fix:=20=EB=8D=94=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=A9=94=EB=89=B4=20=EC=82=AD=EC=A0=9C=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=95=AD=EB=AA=A9=20=EC=9C=84=ED=97=98=EC=83=89=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ui/CellMoreMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/explore/ui/CellMoreMenu.tsx b/apps/web/src/pages/explore/ui/CellMoreMenu.tsx index e92d0f42..362a9580 100644 --- a/apps/web/src/pages/explore/ui/CellMoreMenu.tsx +++ b/apps/web/src/pages/explore/ui/CellMoreMenu.tsx @@ -25,7 +25,7 @@ export const CellMoreMenu = ({ children, onReport }: CellMoreMenuProps) => ( 수정하기 - + 삭제하기 Date: Mon, 20 Jul 2026 16:50:57 +0900 Subject: [PATCH 044/281] =?UTF-8?q?MSG-116=20fix:=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=99=95=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?danger=20=EC=83=89=EC=83=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ui/ReportDialog.tsx | 1 + packages/ui-web/src/modal-card.stories.tsx | 14 ++++++++++++++ packages/ui-web/src/modal-card.tsx | 8 +++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/explore/ui/ReportDialog.tsx b/apps/web/src/pages/explore/ui/ReportDialog.tsx index f0297a22..6c096a23 100644 --- a/apps/web/src/pages/explore/ui/ReportDialog.tsx +++ b/apps/web/src/pages/explore/ui/ReportDialog.tsx @@ -63,6 +63,7 @@ export const ReportDialog = ({ open, onOpenChange }: ReportDialogProps) => { cancelText="취소" confirmText="신고" confirmDisabled={!canSubmitReport(reasonId)} + confirmVariant="danger" onCancel={() => handleOpenChange(false)} onConfirm={handleSubmit} onClose={() => handleOpenChange(false)} diff --git a/packages/ui-web/src/modal-card.stories.tsx b/packages/ui-web/src/modal-card.stories.tsx index 5edc5c95..95fbde94 100644 --- a/packages/ui-web/src/modal-card.stories.tsx +++ b/packages/ui-web/src/modal-card.stories.tsx @@ -41,3 +41,17 @@ export const ConfirmDisabled: Story = {
), }; + +/** confirmVariant="danger" — 삭제·신고 등 파괴적 액션용 확인 버튼. */ +export const ConfirmDanger: Story = { + args: { confirmVariant: "danger", confirmText: "삭제" }, + render: (args) => ( +
+ +
+ 콘텐츠 영역 +
+
+
+ ), +}; diff --git a/packages/ui-web/src/modal-card.tsx b/packages/ui-web/src/modal-card.tsx index 53bf6a61..e2795821 100644 --- a/packages/ui-web/src/modal-card.tsx +++ b/packages/ui-web/src/modal-card.tsx @@ -11,6 +11,8 @@ interface ModalCardProps { confirmText?: string; /** true면 확인 버튼을 비활성(클릭 차단 + 비활성 시각)한다 */ confirmDisabled?: boolean; + /** 확인 버튼 색상 — danger는 삭제·신고 등 파괴적 액션용 (기본 primary) */ + confirmVariant?: "primary" | "danger"; onCancel?: () => void; onConfirm?: () => void; /** 지정하면 우측 상단 닫기 버튼 표시 */ @@ -32,6 +34,7 @@ export const ModalCard = ({ cancelText, confirmText, confirmDisabled, + confirmVariant = "primary", onCancel, onConfirm, onClose, @@ -80,7 +83,10 @@ export const ModalCard = ({ type="button" onClick={onConfirm} disabled={confirmDisabled} - className="h-[48px] min-w-0 flex-1 rounded-full bg-primary text-fm-title leading-none text-primary-foreground transition-[filter] active:brightness-[0.86] disabled:pointer-events-none disabled:opacity-50" + className={cn( + "h-[48px] min-w-0 flex-1 rounded-full text-fm-title leading-none text-primary-foreground transition-[filter] active:brightness-[0.86] disabled:pointer-events-none disabled:opacity-50", + confirmVariant === "danger" ? "bg-error" : "bg-primary", + )} > {confirmText} From 2b7fdc5ebb156724130da23f390c156d576fda76 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 17:06:14 +0900 Subject: [PATCH 045/281] =?UTF-8?q?MSG-116=20fix:=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EC=A4=91=EB=B3=B5=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20canSubmitReport=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EA=B0=80=EB=93=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/features/explore/model/report.ts | 4 +++- apps/web/src/pages/explore/ui/ReportDialog.tsx | 16 +++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/explore/model/report.ts b/apps/web/src/features/explore/model/report.ts index 09cb4e69..2842f04a 100644 --- a/apps/web/src/features/explore/model/report.ts +++ b/apps/web/src/features/explore/model/report.ts @@ -18,7 +18,9 @@ export type ReportReasonId = (typeof REPORT_REASONS)[number]["id"]; * 신고 제출 가능 여부를 판정한다. [L2·L3] * 사유가 선택되지 않았거나(null) 목록에 없는 id면 false, 유효한 id면 true. */ -export const canSubmitReport = (reasonId: string | null): boolean => +export const canSubmitReport = ( + reasonId: string | null, +): reasonId is string => reasonId !== null && REPORT_REASONS.some((r) => r.id === reasonId); /** diff --git a/apps/web/src/pages/explore/ui/ReportDialog.tsx b/apps/web/src/pages/explore/ui/ReportDialog.tsx index 6c096a23..d247f875 100644 --- a/apps/web/src/pages/explore/ui/ReportDialog.tsx +++ b/apps/web/src/pages/explore/ui/ReportDialog.tsx @@ -26,6 +26,7 @@ const TOAST_DURATION_MS = 3000; export const ReportDialog = ({ open, onOpenChange }: ReportDialogProps) => { const [reasonId, setReasonId] = useState(null); const [toastVisible, setToastVisible] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); // 닫힐 때 선택 상태를 초기화해 다시 열면 제출 버튼이 비활성으로 돌아온다 (S9) const handleOpenChange = (next: boolean) => { @@ -34,10 +35,15 @@ export const ReportDialog = ({ open, onOpenChange }: ReportDialogProps) => { }; const handleSubmit = async () => { - if (!canSubmitReport(reasonId)) return; - await submitReport(reasonId as string); - handleOpenChange(false); - setToastVisible(true); + if (!canSubmitReport(reasonId) || isSubmitting) return; + setIsSubmitting(true); + try { + await submitReport(reasonId); + handleOpenChange(false); + setToastVisible(true); + } finally { + setIsSubmitting(false); + } }; // 토스트 자동 소멸 @@ -62,7 +68,7 @@ export const ReportDialog = ({ open, onOpenChange }: ReportDialogProps) => { description={REPORT_GUIDE} cancelText="취소" confirmText="신고" - confirmDisabled={!canSubmitReport(reasonId)} + confirmDisabled={!canSubmitReport(reasonId) || isSubmitting} confirmVariant="danger" onCancel={() => handleOpenChange(false)} onConfirm={handleSubmit} From 5ec74b431179b39367de9c7a568cc7ceaf2a5581 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 17:10:58 +0900 Subject: [PATCH 046/281] =?UTF-8?q?MSG-116=20chore:=20danger=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=8C=80=EB=B9=84=EC=9C=A8=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/decisions/DECISIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index 4a0f09e6..98d801cc 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -19,3 +19,4 @@ | 2026-07-20 | MSG-115 | 결정: 격자 카드 클릭이 기존 지도 이동(MSG-113 S7)과 신규 상세 시트 열기 두 역할을 겸함 | 티켓 문구가 지도 이동을 언급하지 않아 스펙 단계에서 사용자에게 확인 — 기존 동작 유지 + 상세 선택 추가로 확정. 별도 클릭 영역 분리는 과설계로 판단 | | 2026-07-20 | MSG-116 | 결정: `ModalCard`(ui-web)에 `confirmDisabled?: boolean` 후위호환 prop 추가 | 신고 모달의 "사유 미선택 시 제출 비활성"(S6)을 충족하려면 확인 버튼 disabled가 필요한데 기존 `ModalCard`는 미지원. 로컬 재구현 대신 공통 컴포넌트를 확장해 다른 확인형 모달에도 재사용 가능하게 함 — 사용자 승인 후 진행 | | 2026-07-20 | MSG-116 | 결정: 신고 사유 선택 UI(Radix `Select`)를 ui-web으로 승격하지 않고 페이지 로컬(`ReportReasonSelect.tsx`)로 구현 | 소비처가 이 화면 1곳뿐이라 디자인 시스템의 "재사용될 때 승격" 규칙에 미달 — 조기 추상화 대신 두 번째 소비처가 생기면 승격 | +| 2026-07-20 | MSG-116 | 발견(보류): `ModalCard`의 `confirmVariant="danger"`(`bg-error` + `text-primary-foreground`)가 WCAG AA 대비 기준(4.5:1) 미달 — 계산상 약 3.76:1, `fm-title`(15px/600)은 large-text 완화 기준 미충족. 코드리뷰봇 지적, 검증 결과 사실 | 이 조합은 기존 `Button`의 `danger` variant가 먼저 쓰던 색 조합을 그대로 따른 것으로, 이번 PR이 만든 문제가 아니라 디자인 시스템에 이미 있던 이슈(둘 다 이전엔 미사용). 이번 PR에서 로컬로 색만 바꾸면 `Button`과 `ModalCard`의 danger 색이 갈라져 일관성이 깨지므로, `color/error` 토큰 자체를 어둡게 조정하는 디자인 시스템 결정을 후속 티켓으로 분리하기로 사용자와 합의 | From c9aeb392a29e0d80a25082817f0176854e627111 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 17:14:48 +0900 Subject: [PATCH 047/281] =?UTF-8?q?MSG-116=20fix:=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=97=B4=EB=A6=BC=20=EC=A4=91=20Escape?= =?UTF-8?q?=EA=B0=80=20=EC=83=81=EC=84=B8=20=EC=8B=9C=ED=8A=B8=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=AB=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/explore/ui/CellDetailSheet.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx index 14b9c784..fc2d81cf 100644 --- a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx +++ b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx @@ -34,13 +34,15 @@ export const CellDetailSheet = ({ cell, className }: CellDetailSheetProps) => { const duration = formatDuration(activeVideo?.durationSec); // 우측 컬럼이 목록을 상당 부분 덮으므로 Escape로도 닫을 수 있게 한다 (키보드 접근성) + // 신고 모달이 열려 있을 때는 Radix Dialog가 자체적으로 Escape를 처리하므로 + // 이 리스너가 상세 시트까지 함께 닫지 않도록 가드한다. useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") close(); + if (e.key === "Escape" && !reportOpen) close(); }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [close]); + }, [close, reportOpen]); return (
Date: Mon, 20 Jul 2026 19:41:20 +0900 Subject: [PATCH 048/281] =?UTF-8?q?MSG-117=20feat:=20=EC=8B=A0=EA=B3=A0?= =?UTF-8?q?=EC=9A=A9=20=EB=AA=A8=EB=8B=AC=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/layouts/AppLayout.tsx | 3 + .../upload/model/upload-modal-store.ts | 20 +++ .../upload/model/upload-validation.test.ts | 100 ++++++++++++ .../upload/model/upload-validation.ts | 45 ++++++ .../src/features/upload/ui/UploadDropzone.tsx | 87 ++++++++++ .../src/features/upload/ui/UploadModal.tsx | 149 ++++++++++++++++++ .../web/src/pages/map-home/ui/MapControls.tsx | 2 +- apps/web/src/widgets/map-shell/MapShell.tsx | 12 +- .../src/widgets/side-rail-nav/SideRailNav.tsx | 7 + docs/decisions/DECISIONS.md | 2 + 10 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/features/upload/model/upload-modal-store.ts create mode 100644 apps/web/src/features/upload/model/upload-validation.test.ts create mode 100644 apps/web/src/features/upload/model/upload-validation.ts create mode 100644 apps/web/src/features/upload/ui/UploadDropzone.tsx create mode 100644 apps/web/src/features/upload/ui/UploadModal.tsx diff --git a/apps/web/src/app/layouts/AppLayout.tsx b/apps/web/src/app/layouts/AppLayout.tsx index 6bc95af4..5434f45e 100644 --- a/apps/web/src/app/layouts/AppLayout.tsx +++ b/apps/web/src/app/layouts/AppLayout.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router-dom"; +import { UploadModal } from "@/features/upload/ui/UploadModal"; import { SideRailNav } from "@/widgets/side-rail-nav/SideRailNav"; /** 웹 공통 셸 — 좌측 SideRail 고정, 나머지 영역에 페이지(Outlet) 렌더링 */ @@ -8,5 +9,7 @@ export const AppLayout = () => (
+ {/* 두 진입점(사이드레일·지도 FAB) 공통 조상에 1회 마운트 — 열림 상태는 전역 스토어 (Q2) */} + ); diff --git a/apps/web/src/features/upload/model/upload-modal-store.ts b/apps/web/src/features/upload/model/upload-modal-store.ts new file mode 100644 index 00000000..d7d99f9a --- /dev/null +++ b/apps/web/src/features/upload/model/upload-modal-store.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; + +interface UploadModalState { + /** 업로드 모달 열림 여부 */ + open: boolean; + openModal: () => void; + closeModal: () => void; +} + +/** + * 업로드 모달 열림 상태 — 전역 UI 플래그. [Q1] + * 두 진입점(사이드레일=AppLayout, 지도 FAB=MapShell)이 위젯 경계를 넘어 같은 모달을 + * 열어야 하므로 로컬 state 리프팅 대신 전역 스토어로 둔다(sidebar-store 선례). + * 라우터를 참조하지 않는다 — 네비게이션이 아니라 오버레이 토글이다(RN 경계). + */ +export const useUploadModalStore = create((set) => ({ + open: false, + openModal: () => set({ open: true }), + closeModal: () => set({ open: false }), +})); diff --git a/apps/web/src/features/upload/model/upload-validation.test.ts b/apps/web/src/features/upload/model/upload-validation.test.ts new file mode 100644 index 00000000..49e0507b --- /dev/null +++ b/apps/web/src/features/upload/model/upload-validation.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + ALLOWED_VIDEO_EXTENSIONS, + canSubmitUpload, + hasAllowedVideoExtension, + isValidVideoFile, + isWithinSizeLimit, + MAX_UPLOAD_BYTES, +} from "./upload-validation"; + +describe("hasAllowedVideoExtension", () => { + // AC14: MP4·MOV 확장자만 유효로 판정한다 (대소문자 무관) + it("mp4 / mov 확장자는 대소문자와 무관하게 유효로 판정한다", () => { + expect(hasAllowedVideoExtension("clip.mp4")).toBe(true); + expect(hasAllowedVideoExtension("clip.MP4")).toBe(true); + expect(hasAllowedVideoExtension("clip.mov")).toBe(true); + expect(hasAllowedVideoExtension("clip.MOV")).toBe(true); + }); + + it("확장자가 여러 점 뒤에 있어도 마지막 확장자로 판정한다", () => { + expect(hasAllowedVideoExtension("2026.07.20.mp4")).toBe(true); + }); + + // AC14: 그 외 확장자는 무효로 판정한다 + it("허용 목록에 없는 확장자는 무효로 판정한다", () => { + expect(hasAllowedVideoExtension("photo.png")).toBe(false); + expect(hasAllowedVideoExtension("video.avi")).toBe(false); + expect(hasAllowedVideoExtension("doc.pdf")).toBe(false); + }); + + it("확장자가 없는 파일명은 무효로 판정한다", () => { + expect(hasAllowedVideoExtension("mp4")).toBe(false); + expect(hasAllowedVideoExtension("clip.")).toBe(false); + expect(hasAllowedVideoExtension("")).toBe(false); + }); +}); + +describe("isWithinSizeLimit", () => { + // AC14: 500MB 이하만 유효로 판정한다 + it("정확히 500MB는 유효로 판정한다", () => { + expect(isWithinSizeLimit(MAX_UPLOAD_BYTES)).toBe(true); + }); + + it("500MB 미만은 유효로 판정한다", () => { + expect(isWithinSizeLimit(1)).toBe(true); + expect(isWithinSizeLimit(MAX_UPLOAD_BYTES - 1)).toBe(true); + }); + + // AC14: 500MB 초과는 무효로 판정한다 + it("500MB를 초과하면 무효로 판정한다", () => { + expect(isWithinSizeLimit(MAX_UPLOAD_BYTES + 1)).toBe(false); + }); +}); + +describe("isValidVideoFile", () => { + // AC14: 확장자·용량을 모두 만족해야 유효 + it("MP4·MOV이면서 500MB 이하인 파일만 유효로 판정한다", () => { + expect(isValidVideoFile({ name: "a.mp4", size: 1000 })).toBe(true); + expect(isValidVideoFile({ name: "a.mov", size: MAX_UPLOAD_BYTES })).toBe( + true, + ); + }); + + it("확장자는 맞아도 용량이 초과하면 무효로 판정한다", () => { + expect( + isValidVideoFile({ name: "a.mp4", size: MAX_UPLOAD_BYTES + 1 }), + ).toBe(false); + }); + + it("용량은 맞아도 확장자가 아니면 무효로 판정한다", () => { + expect(isValidVideoFile({ name: "a.png", size: 1000 })).toBe(false); + }); +}); + +describe("canSubmitUpload", () => { + // AC15 (Q5): 유효 파일이 선택되면 업로드 버튼을 활성으로 판정한다 + it("유효한 파일이 선택되면 true를 반환한다", () => { + expect(canSubmitUpload({ name: "a.mp4", size: 1000 })).toBe(true); + }); + + // AC15 (Q5): 파일 미선택(null)이면 비활성으로 판정한다 + it("파일이 선택되지 않았으면(null) false를 반환한다", () => { + expect(canSubmitUpload(null)).toBe(false); + }); + + // AC15: 선택되었어도 무효 파일이면 비활성으로 판정한다 + it("선택된 파일이 무효(확장자·용량 위반)면 false를 반환한다", () => { + expect(canSubmitUpload({ name: "a.avi", size: 1000 })).toBe(false); + expect( + canSubmitUpload({ name: "a.mp4", size: MAX_UPLOAD_BYTES + 1 }), + ).toBe(false); + }); +}); + +describe("상수", () => { + it("허용 확장자는 mp4·mov이고 최대 용량은 500MB다", () => { + expect(ALLOWED_VIDEO_EXTENSIONS).toEqual(["mp4", "mov"]); + expect(MAX_UPLOAD_BYTES).toBe(500 * 1024 * 1024); + }); +}); diff --git a/apps/web/src/features/upload/model/upload-validation.ts b/apps/web/src/features/upload/model/upload-validation.ts new file mode 100644 index 00000000..01cdf353 --- /dev/null +++ b/apps/web/src/features/upload/model/upload-validation.ts @@ -0,0 +1,45 @@ +/** + * 영상 업로드 파일 검증 — 순수 함수/상수 (MSG-117 AC14·AC15). + * 플랫폼 API(window·File·DOM 등)를 참조하지 않는다 — 이름·용량만 받아 판정하므로 RN 재사용 대상. + * 길이(≤60초) 검증은 이번 범위 제외(플랫폼 미디어 메타데이터 필요) — 안내 텍스트로만 노출. + */ + +/** 업로드 허용 영상 확장자(소문자). [AC14] */ +export const ALLOWED_VIDEO_EXTENSIONS = ["mp4", "mov"] as const; + +/** 최대 업로드 용량 — 500MB(바이트). [AC14] */ +export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; + +/** 검증 대상 — 플랫폼 File이 아닌 이름·용량만 담은 중립 형태(RN 경계). */ +export interface UploadCandidate { + name: string; + size: number; +} + +/** + * 파일명 확장자가 허용 목록(MP4·MOV)에 속하는지 판정한다. 대소문자 무관. [AC14] + * 확장자가 없거나 빈 경우 무효. + */ +export const hasAllowedVideoExtension = (name: string): boolean => { + const dot = name.lastIndexOf("."); + if (dot < 0) return false; + const ext = name.slice(dot + 1).toLowerCase(); + return (ALLOWED_VIDEO_EXTENSIONS as readonly string[]).includes(ext); +}; + +/** 용량이 최대 허용치(500MB) 이하인지 판정한다. [AC14] */ +export const isWithinSizeLimit = (size: number): boolean => + size <= MAX_UPLOAD_BYTES; + +/** 확장자·용량을 모두 만족하는 유효 영상 파일인지 판정한다. [AC14] */ +export const isValidVideoFile = (file: UploadCandidate): boolean => + hasAllowedVideoExtension(file.name) && isWithinSizeLimit(file.size); + +/** + * 업로드 버튼 활성/비활성 판정. [AC15 · Q5] + * 유효 파일이 선택되면 true, 미선택(null)이거나 무효 파일이면 false. + * 제목 입력은 제출 필수 조건이 아니다(Q5). + */ +export const canSubmitUpload = ( + file: UploadCandidate | null, +): file is UploadCandidate => file !== null && isValidVideoFile(file); diff --git a/apps/web/src/features/upload/ui/UploadDropzone.tsx b/apps/web/src/features/upload/ui/UploadDropzone.tsx new file mode 100644 index 00000000..7c0c467a --- /dev/null +++ b/apps/web/src/features/upload/ui/UploadDropzone.tsx @@ -0,0 +1,87 @@ +import { type DragEvent, useRef, useState } from "react"; +import { Upload } from "lucide-react"; +import { cn } from "@fillmap/ui-web"; +import { + isValidVideoFile, + type UploadCandidate, +} from "@/features/upload/model/upload-validation"; + +interface UploadDropzoneProps { + /** 현재 선택된 파일명 (없으면 null) — 표시는 부모 state 기준 */ + selectedName: string | null; + /** 유효한 파일이 선택되면 호출 (무효 파일은 거부되어 호출되지 않음) */ + onSelectFile: (file: UploadCandidate) => void; +} + +const CONSTRAINT_TEXT = "최대 60초 · MP4, MOV · 500MB 이하"; +const REJECT_TEXT = "MP4·MOV 형식, 500MB 이하 영상만 올릴 수 있어요"; + +/** + * 드래그앤드롭 + 클릭 파일 선택 셸 (feature-local). [AC4·AC5] + * 클릭 시 숨은 file input을 트리거하고 drag/drop을 처리한다. + * 무효 파일(확장자·용량 위반)은 선택을 거부하고 안내 텍스트를 노출한다(Q4) — 선택 상태는 갱신 안 함. + * File→중립 형태({name,size}) 변환은 이 UI 경계에서 수행하고, 판정은 model의 순수 함수에 위임한다. + */ +export const UploadDropzone = ({ + selectedName, + onSelectFile, +}: UploadDropzoneProps) => { + const inputRef = useRef(null); + const [dragActive, setDragActive] = useState(false); + const [rejected, setRejected] = useState(false); + + const handleFile = (file: File | undefined) => { + if (!file) return; + const candidate: UploadCandidate = { name: file.name, size: file.size }; + if (!isValidVideoFile(candidate)) { + setRejected(true); + return; + } + setRejected(false); + onSelectFile(candidate); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + setDragActive(false); + handleFile(event.dataTransfer.files[0]); + }; + + return ( + + ); +}; diff --git a/apps/web/src/features/upload/ui/UploadModal.tsx b/apps/web/src/features/upload/ui/UploadModal.tsx new file mode 100644 index 00000000..bc9d6030 --- /dev/null +++ b/apps/web/src/features/upload/ui/UploadModal.tsx @@ -0,0 +1,149 @@ +import { type ReactNode, useState } from "react"; +import { MapPin } from "lucide-react"; +import { Dialog } from "radix-ui"; +import { cn, Input, ModalCard } from "@fillmap/ui-web"; +import { MOCK_CELLS } from "@/entities/cell"; +import { useUploadModalStore } from "@/features/upload/model/upload-modal-store"; +import { + canSubmitUpload, + type UploadCandidate, +} from "@/features/upload/model/upload-validation"; +import { UploadDropzone } from "./UploadDropzone"; + +const MODAL_SUBTITLE = "지금 위치의 격자에 순간을 기록하세요"; + +// 위치→격자 해석 로직은 이번 범위 아님 — mock 격자(A-14) 기반 정적 라벨 (Q6·AC7) +const CURRENT_CELL = MOCK_CELLS.find((cell) => cell.id === "A-14"); +const LOCATION_LABEL = `${CURRENT_CELL?.label ?? "현재 격자"} (현재 위치)`; + +/** AI 안내 / 최종 확인 박스 — 정적 프레젠테이션 (AC8·AC9) */ +const InfoBox = ({ + title, + body, + tone, +}: { + title: string; + body: ReactNode; + tone: "soft" | "dark"; +}) => ( +
+ + {title} + + + {body} + +
+); + +/** + * 영상 업로드 모달 — Radix Dialog(오버레이·포털·포커스 트랩·Esc·scrim)로 ModalCard를 감싼다. + * 두 진입점 공통 조상(AppLayout)에 1회 마운트되고 열림 상태는 전역 스토어가 관리한다(Q1·Q2). + * 제목·선택 파일은 로컬 state이며 닫힐 때 초기화된다(AC10). + * 취소/✕/scrim/Esc/업로드 모두 닫기만 한다 — 실제 업로드 연동은 범위 밖(Q5, 목업). + */ +export const UploadModal = () => { + const open = useUploadModalStore((s) => s.open); + const closeModal = useUploadModalStore((s) => s.closeModal); + const [title, setTitle] = useState(""); + const [file, setFile] = useState(null); + + // 닫힐 때마다 입력을 초기화해 다시 열면 이전 제목·파일이 남지 않는다 (AC10) + const close = () => { + setTitle(""); + setFile(null); + closeModal(); + }; + + // Esc·scrim 클릭은 Radix가 onOpenChange(false)로 전달 — 이 모달만 닫는다 (AC11·AC13) + const handleOpenChange = (next: boolean) => { + if (!next) close(); + }; + + return ( + + + + + 영상 업로드 + + + +
+ 제목 + setTitle(event.target.value)} + className="border-border bg-surface-soft" + /> +
+ +
+ + 위치 태그 + + + + {LOCATION_LABEL} + + {/* 격자 재선택은 범위 밖 — 노출만, 비동작 (AC7) */} + +
+ + + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/pages/map-home/ui/MapControls.tsx b/apps/web/src/pages/map-home/ui/MapControls.tsx index 46edd3d2..80192762 100644 --- a/apps/web/src/pages/map-home/ui/MapControls.tsx +++ b/apps/web/src/pages/map-home/ui/MapControls.tsx @@ -2,7 +2,7 @@ import { Upload } from "lucide-react"; import { Fab, MapIconButton, ZoomControl } from "@fillmap/ui-web"; interface MapControlsProps { - /** 업로드 라우트로 이동 (콜백 주입 — RN 경계) */ + /** 업로드 모달 열기 (콜백 주입 — RN 경계) */ onUpload: () => void; /** 현재 위치(폴백 시 서울 시청)로 재이동 */ onLocate: () => void; diff --git a/apps/web/src/widgets/map-shell/MapShell.tsx b/apps/web/src/widgets/map-shell/MapShell.tsx index b6c178b9..9ec7371b 100644 --- a/apps/web/src/widgets/map-shell/MapShell.tsx +++ b/apps/web/src/widgets/map-shell/MapShell.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Outlet, useNavigate } from "react-router-dom"; -import { ROUTES } from "@/app/routes"; +import { Outlet } from "react-router-dom"; import type { LatLng } from "@/entities/cell"; +import { useUploadModalStore } from "@/features/upload/model/upload-modal-store"; import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { MapCanvas, type MapCanvasHandle } from "@/pages/map-home/ui/MapCanvas"; import { MapControls } from "@/pages/map-home/ui/MapControls"; @@ -16,10 +16,9 @@ import type { MapShellContext } from "./use-map-shell"; * 지도 SDK import는 MapCanvas 경계 안에만 두고, 셸은 배치와 명령 주입만 담당한다. */ export const MapShell = () => { - const navigate = useNavigate(); const setViewport = useViewportStore((s) => s.setViewport); const collapsed = useSidebarStore((s) => s.collapsed); - const setCollapsed = useSidebarStore((s) => s.setCollapsed); + const openUploadModal = useUploadModalStore((s) => s.openModal); const mapRef = useRef(null); const [initialCenter, setInitialCenter] = useState(SEOUL_CITY_HALL); @@ -67,10 +66,7 @@ export const MapShell = () => {
{ - setCollapsed(false); - navigate(ROUTES.upload); - }} + onUpload={openUploadModal} onLocate={context.locate} onZoomIn={context.zoomIn} onZoomOut={context.zoomOut} diff --git a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx index 1e76ff29..12290bb9 100644 --- a/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx +++ b/apps/web/src/widgets/side-rail-nav/SideRailNav.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { SideRail, type SideRailItem } from "@fillmap/ui-web"; import { ROUTES, getActiveNavKey, isNavKey, type NavKey } from "@/app/routes"; import { useExploreFilterStore } from "@/features/explore/model/explore-filter-store"; +import { useUploadModalStore } from "@/features/upload/model/upload-modal-store"; import { useSidebarStore } from "@/widgets/map-shell/sidebar-store"; const items: (SideRailItem & { key: NavKey })[] = [ @@ -23,6 +24,7 @@ export const SideRailNav = () => { const setCollapsed = useSidebarStore((s) => s.setCollapsed); const toggle = useSidebarStore((s) => s.toggle); const clearFilters = useExploreFilterStore((s) => s.clearFilters); + const openUploadModal = useUploadModalStore((s) => s.openModal); return ( { activeKey={getActiveNavKey(pathname)} onSelect={(key) => { if (!isNavKey(key)) return; + // 업로드는 페이지 이동 대신 모달을 연다 (URL 불변) — AC1 + if (key === "upload") { + openUploadModal(); + return; + } // 활성 탭 재클릭 → 접기/펼치기 토글, 다른 탭 → 이동하며 펼침 if (key === getActiveNavKey(pathname)) { toggle(); diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index 98d801cc..6d348433 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -20,3 +20,5 @@ | 2026-07-20 | MSG-116 | 결정: `ModalCard`(ui-web)에 `confirmDisabled?: boolean` 후위호환 prop 추가 | 신고 모달의 "사유 미선택 시 제출 비활성"(S6)을 충족하려면 확인 버튼 disabled가 필요한데 기존 `ModalCard`는 미지원. 로컬 재구현 대신 공통 컴포넌트를 확장해 다른 확인형 모달에도 재사용 가능하게 함 — 사용자 승인 후 진행 | | 2026-07-20 | MSG-116 | 결정: 신고 사유 선택 UI(Radix `Select`)를 ui-web으로 승격하지 않고 페이지 로컬(`ReportReasonSelect.tsx`)로 구현 | 소비처가 이 화면 1곳뿐이라 디자인 시스템의 "재사용될 때 승격" 규칙에 미달 — 조기 추상화 대신 두 번째 소비처가 생기면 승격 | | 2026-07-20 | MSG-116 | 발견(보류): `ModalCard`의 `confirmVariant="danger"`(`bg-error` + `text-primary-foreground`)가 WCAG AA 대비 기준(4.5:1) 미달 — 계산상 약 3.76:1, `fm-title`(15px/600)은 large-text 완화 기준 미충족. 코드리뷰봇 지적, 검증 결과 사실 | 이 조합은 기존 `Button`의 `danger` variant가 먼저 쓰던 색 조합을 그대로 따른 것으로, 이번 PR이 만든 문제가 아니라 디자인 시스템에 이미 있던 이슈(둘 다 이전엔 미사용). 이번 PR에서 로컬로 색만 바꾸면 `Button`과 `ModalCard`의 danger 색이 갈라져 일관성이 깨지므로, `color/error` 토큰 자체를 어둡게 조정하는 디자인 시스템 결정을 후속 티켓으로 분리하기로 사용자와 합의 | +| 2026-07-20 | MSG-117 | 결정: 위치 태그 pill을 `Chip`/`CellBadge` 재사용 대신 feature-local 인라인으로 구현 | 스펙은 두 컴포넌트를 Figma 확인 후 택1 후보로 뒀으나, Figma(13399:1555) 태그는 `bg-primary/10` + primary 텍스트 + MapPin 아이콘의 위치 표시 pill로, 필터 토글용 `Chip`(h-32 solid-active)·격자 배지용 `CellBadge`(solid primary bg + 흰 텍스트)와 시각·역할이 모두 다름. 강제 재사용 시 스타일 오버라이드로 결합만 커져 역할 겹침이 없는 로컬 마크업으로 둠(정적·비동작 표시라 도메인 로직 없음) | +| 2026-07-20 | MSG-117 | 결정: 파일 검증(`upload-validation`)을 플랫폼 `File`이 아닌 중립 `{name,size}`(`UploadCandidate`)로 받는 순수 함수로 설계, DOM `File`→중립 변환은 `UploadDropzone`(UI 경계)에서 수행 | RN 경계 규칙 — 모델 레이어가 웹 전용 `File`/DOM에 의존하면 RN 재사용 시 전면 리팩터. 이름·용량만으로 판정 가능하므로 중립 타입으로 내려 확장을 파일 이동 수준으로 유지 | From 83c886a7063b7a5f3cb13048966092624e4c1752 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 20:00:40 +0900 Subject: [PATCH 049/281] =?UTF-8?q?MSG-117=20feat:=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=AA=A8=EB=8B=AC=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/features/upload/ui/UploadModal.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/upload/ui/UploadModal.tsx b/apps/web/src/features/upload/ui/UploadModal.tsx index bc9d6030..ed3f532f 100644 --- a/apps/web/src/features/upload/ui/UploadModal.tsx +++ b/apps/web/src/features/upload/ui/UploadModal.tsx @@ -100,8 +100,14 @@ export const UploadModal = () => { />
- 제목 + setTitle(event.target.value)} From 26f2d7c4c28c7b932767d177775591a72205e618 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 20:02:53 +0900 Subject: [PATCH 050/281] =?UTF-8?q?MSG-117=20feat:=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=AA=A8=EB=8B=AC=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/features/upload/ui/UploadModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/upload/ui/UploadModal.tsx b/apps/web/src/features/upload/ui/UploadModal.tsx index ed3f532f..7cb839ea 100644 --- a/apps/web/src/features/upload/ui/UploadModal.tsx +++ b/apps/web/src/features/upload/ui/UploadModal.tsx @@ -123,10 +123,11 @@ export const UploadModal = () => { {LOCATION_LABEL} - {/* 격자 재선택은 범위 밖 — 노출만, 비동작 (AC7) */} + {/* 격자 재선택은 범위 밖 — disabled로 비활성 표시해 클릭 오인 방지 (AC7) */} From 5cb549da23b7b2656d3b9b0e0c98edb682f1efee Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 20:10:35 +0900 Subject: [PATCH 051/281] =?UTF-8?q?MSG-117=20feat:=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=AA=A8=EB=8B=AC=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/upload/ui/UploadDropzone.tsx | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/apps/web/src/features/upload/ui/UploadDropzone.tsx b/apps/web/src/features/upload/ui/UploadDropzone.tsx index 7c0c467a..2731e175 100644 --- a/apps/web/src/features/upload/ui/UploadDropzone.tsx +++ b/apps/web/src/features/upload/ui/UploadDropzone.tsx @@ -48,20 +48,7 @@ export const UploadDropzone = ({ }; return ( - + + ); }; From 8e2f52df0d42704a009714d5a6e18cbad934a0bd Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Mon, 20 Jul 2026 21:34:29 +0900 Subject: [PATCH 052/281] =?UTF-8?q?MSG-118=20feat:=20AI=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B5=AC=EA=B0=84=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/model/highlight-selection.test.ts | 234 ++++++++++++++++++ .../upload/model/highlight-selection.ts | 222 +++++++++++++++++ .../upload/model/use-highlight-selection.ts | 38 +++ .../src/features/upload/ui/HighlightStep.tsx | 115 +++++++++ .../src/features/upload/ui/SegmentList.tsx | 32 +++ .../web/src/features/upload/ui/SegmentRow.tsx | 71 ++++++ .../src/features/upload/ui/SegmentTrimmer.tsx | 194 +++++++++++++++ .../src/features/upload/ui/UploadDropzone.tsx | 10 +- .../src/features/upload/ui/UploadModal.tsx | 168 ++++++++----- .../src/features/upload/ui/VideoPreview.tsx | 72 ++++++ .../features/upload/ui/use-video-duration.ts | 48 ++++ docs/decisions/DECISIONS.md | 3 + 12 files changed, 1148 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/features/upload/model/highlight-selection.test.ts create mode 100644 apps/web/src/features/upload/model/highlight-selection.ts create mode 100644 apps/web/src/features/upload/model/use-highlight-selection.ts create mode 100644 apps/web/src/features/upload/ui/HighlightStep.tsx create mode 100644 apps/web/src/features/upload/ui/SegmentList.tsx create mode 100644 apps/web/src/features/upload/ui/SegmentRow.tsx create mode 100644 apps/web/src/features/upload/ui/SegmentTrimmer.tsx create mode 100644 apps/web/src/features/upload/ui/VideoPreview.tsx create mode 100644 apps/web/src/features/upload/ui/use-video-duration.ts diff --git a/apps/web/src/features/upload/model/highlight-selection.test.ts b/apps/web/src/features/upload/model/highlight-selection.test.ts new file mode 100644 index 00000000..b4b93635 --- /dev/null +++ b/apps/web/src/features/upload/model/highlight-selection.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import { + adjustEndHandle, + adjustStartHandle, + buildMockHighlights, + canProceedToNextStep, + clampSegment, + createInitialSelection, + formatTimecode, + getSelectedSegment, + moveSegment, + SEGMENT_MAX_SEC, + SEGMENT_MIN_SEC, + selectAi, + selectManual, + shouldOfferHighlight, + toSelectionResult, +} from "./highlight-selection"; + +describe("shouldOfferHighlight", () => { + // L1: duration > 5초일 때만 true, 정확히 5초 및 그 이하이면 false + it("영상 길이가 5초를 초과하면 true를 반환한다", () => { + expect(shouldOfferHighlight(5.01)).toBe(true); + expect(shouldOfferHighlight(6)).toBe(true); + expect(shouldOfferHighlight(60)).toBe(true); + }); + + it("영상 길이가 정확히 5초이거나 그 이하이면 false를 반환한다", () => { + expect(shouldOfferHighlight(5)).toBe(false); + expect(shouldOfferHighlight(4.9)).toBe(false); + expect(shouldOfferHighlight(0)).toBe(false); + }); +}); + +describe("adjustEndHandle / adjustStartHandle — 최소 5초 clamp", () => { + // L2: 직접 구간 길이는 최소 5초로 clamp된다 + it("끝 핸들을 시작+5초보다 앞으로 옮기려 하면 시작+5초에서 멈춘다", () => { + expect(adjustEndHandle({ start: 10, end: 20 }, 12, 100).end).toBe(15); + }); + + it("시작 핸들을 끝-5초보다 뒤로 옮기려 하면 끝-5초에서 멈춘다", () => { + expect(adjustStartHandle({ start: 10, end: 20 }, 18).start).toBe(15); + }); +}); + +describe("adjustEndHandle / adjustStartHandle — 최대 30초 clamp", () => { + // L3: 직접 구간 길이는 최대 30초로 clamp된다 + it("끝 핸들을 시작+30초보다 뒤로 옮기려 하면 시작+30초에서 멈춘다", () => { + expect(adjustEndHandle({ start: 10, end: 20 }, 50, 100).end).toBe(40); + }); + + it("시작 핸들을 끝-30초보다 앞으로 옮기려 하면 끝-30초에서 멈춘다", () => { + expect(adjustStartHandle({ start: 20, end: 40 }, 0).start).toBe(10); + }); +}); + +describe("adjustEndHandle / adjustStartHandle — 영상 경계 [0, duration]", () => { + // L4: 핸들은 영상 경계를 벗어나지 않는다 + it("시작 핸들은 0 미만으로 이동하지 않는다", () => { + expect(adjustStartHandle({ start: 5, end: 20 }, -3).start).toBe(0); + }); + + it("끝 핸들은 duration 초과로 이동하지 않는다", () => { + expect(adjustEndHandle({ start: 80, end: 90 }, 200, 100).end).toBe(100); + }); +}); + +describe("selectAi / selectManual — 상호 배타 단일 선택", () => { + // L5: AI 추천 선택과 직접 구간 선택은 동시에 selected 상태가 되지 않는다 + it("AI 추천을 선택하면 직접 지정 구간의 선택이 해제된다", () => { + const initial = createInitialSelection(100); + const manual = selectManual(initial, { start: 10, end: 20 }); + expect(manual.mode).toBe("manual"); + + const ai = selectAi(manual, { + id: "mock-1", + start: 0, + end: 5, + reason: "테스트", + }); + expect(ai.mode).toBe("ai"); + expect(getSelectedSegment(ai)).toEqual({ start: 0, end: 5 }); + }); + + it("직접 구간을 지정하면 AI 추천 선택이 해제된다", () => { + const initial = createInitialSelection(100); + const ai = selectAi(initial, { + id: "mock-1", + start: 0, + end: 5, + reason: "테스트", + }); + expect(ai.mode).toBe("ai"); + + const manual = selectManual(ai, { start: 10, end: 20 }); + expect(manual.mode).toBe("manual"); + expect(manual.selectedAi).toBeNull(); + expect(getSelectedSegment(manual)).toEqual({ start: 10, end: 20 }); + }); +}); + +describe("canProceedToNextStep", () => { + // L6: 선택된 구간이 없으면 false, AI 또는 직접 구간이 선택되면 true + it("선택된 구간이 없으면 false를 반환한다", () => { + expect(canProceedToNextStep(createInitialSelection(100))).toBe(false); + }); + + it("AI 추천이 선택되면 true를 반환한다", () => { + const ai = selectAi(createInitialSelection(100), { + id: "mock-1", + start: 0, + end: 5, + reason: "테스트", + }); + expect(canProceedToNextStep(ai)).toBe(true); + }); + + it("직접 구간이 선택되면 true를 반환한다", () => { + const manual = selectManual(createInitialSelection(100), { + start: 10, + end: 20, + }); + expect(canProceedToNextStep(manual)).toBe(true); + }); +}); + +describe("formatTimecode", () => { + // L7: 초를 m:ss 형식으로 포맷한다 + it("초를 m:ss 형식으로 포맷한다", () => { + expect(formatTimecode(3)).toBe("0:03"); + expect(formatTimecode(42)).toBe("0:42"); + expect(formatTimecode(75)).toBe("1:15"); + }); + + it("소수 초는 내림해 포맷한다", () => { + expect(formatTimecode(3.9)).toBe("0:03"); + expect(formatTimecode(0)).toBe("0:00"); + }); +}); + +describe("buildMockHighlights", () => { + // L8: 3~5개의 추천 구간, 각 구간은 5~30초 제약과 [0, duration] 범위를 만족한다 + it.each([6, 10, 25, 60, 120])( + "duration=%d초: 3~5개 구간을 생성하며 각 구간이 제약을 만족한다", + (duration) => { + const highlights = buildMockHighlights(duration); + expect(highlights.length).toBeGreaterThanOrEqual(3); + expect(highlights.length).toBeLessThanOrEqual(5); + + for (const seg of highlights) { + const length = seg.end - seg.start; + expect(seg.start).toBeGreaterThanOrEqual(0); + expect(seg.end).toBeLessThanOrEqual(duration); + expect(length).toBeGreaterThanOrEqual( + Math.min(SEGMENT_MIN_SEC, duration), + ); + expect(length).toBeLessThanOrEqual(SEGMENT_MAX_SEC); + expect(seg.reason.length).toBeGreaterThan(0); + } + }, + ); + + it("각 구간에 고유 id와 사유 텍스트가 있다", () => { + const highlights = buildMockHighlights(120); + const ids = new Set(highlights.map((h) => h.id)); + expect(ids.size).toBe(highlights.length); + }); +}); + +describe("toSelectionResult", () => { + // L9: 시작 시각·끝 시각·선택 방식을 담은 payload를 반환한다 + it("AI 선택 결과는 start·end·mode('ai')를 담는다", () => { + const ai = selectAi(createInitialSelection(100), { + id: "mock-1", + start: 3, + end: 12, + reason: "테스트", + }); + expect(toSelectionResult(ai)).toEqual({ start: 3, end: 12, mode: "ai" }); + }); + + it("직접 구간 선택 결과는 start·end·mode('manual')을 담는다", () => { + const manual = selectManual(createInitialSelection(100), { + start: 10, + end: 40, + }); + expect(toSelectionResult(manual)).toEqual({ + start: 10, + end: 40, + mode: "manual", + }); + }); + + it("선택이 없으면 null을 반환한다", () => { + expect(toSelectionResult(createInitialSelection(100))).toBeNull(); + }); +}); + +describe("clampSegment / moveSegment — 트리머 보조", () => { + it("clampSegment는 길이를 5~30초와 [0, duration] 범위로 정규화한다", () => { + // 너무 짧은 구간은 최소 5초로 확장 + expect(clampSegment({ start: 10, end: 12 }, 100)).toEqual({ + start: 10, + end: 15, + }); + // 너무 긴 구간은 최대 30초로 축소 + expect(clampSegment({ start: 10, end: 90 }, 100)).toEqual({ + start: 10, + end: 40, + }); + // duration보다 큰 구간은 영상 범위로 제한 + expect(clampSegment({ start: 4, end: 6 }, 5)).toEqual({ + start: 0, + end: 5, + }); + }); + + it("moveSegment는 길이를 유지한 채 구간을 이동하고 경계에서 멈춘다", () => { + expect(moveSegment({ start: 10, end: 20 }, 5, 100)).toEqual({ + start: 15, + end: 25, + }); + // 오른쪽 경계 초과 시 길이 유지하며 정지 + expect(moveSegment({ start: 90, end: 100 }, 20, 100)).toEqual({ + start: 90, + end: 100, + }); + // 왼쪽 경계 초과 시 0에서 정지 + expect(moveSegment({ start: 5, end: 15 }, -20, 100)).toEqual({ + start: 0, + end: 10, + }); + }); +}); diff --git a/apps/web/src/features/upload/model/highlight-selection.ts b/apps/web/src/features/upload/model/highlight-selection.ts new file mode 100644 index 00000000..70448460 --- /dev/null +++ b/apps/web/src/features/upload/model/highlight-selection.ts @@ -0,0 +1,222 @@ +/** + * AI 하이라이트 추천 — 선택/트리머 순수 로직 (MSG-118 L1~L9). + * 플랫폼 API(window·File·DOM·video 등)를 참조하지 않는다 — 초 단위 숫자만 다루므로 RN 재사용 대상. + * 실제 영상 duration 캡처·재생은 UI 경계(use-video-duration / VideoPreview)에 격리한다. + */ + +/** 직접 구간 최소 길이 — 5초. [L2] */ +export const SEGMENT_MIN_SEC = 5; +/** 직접 구간 최대 길이 — 30초. [L3] */ +export const SEGMENT_MAX_SEC = 30; + +/** 시간 구간 — 시작·끝(초). */ +export interface Segment { + start: number; + end: number; +} + +/** AI 추천 구간 — 구간 + 식별자 + 추천 사유. */ +export interface HighlightSuggestion extends Segment { + id: string; + reason: string; +} + +/** 선택 방식 — AI 추천 vs 직접 지정. */ +export type SelectionMode = "ai" | "manual"; + +/** + * 선택 상태 — mode가 단일 선택을 강제한다(상호 배타). [L5] + * manualSegment는 트리머의 현재 구간 값으로 항상 유효 값을 보유하며, + * mode==="manual"일 때만 "선택된 구간"으로 취급된다. + */ +export interface HighlightSelectionState { + /** 현재 선택 방식 — null이면 미선택 */ + mode: SelectionMode | null; + /** AI 방식으로 선택된 추천 구간 — 미선택이면 null */ + selectedAi: HighlightSuggestion | null; + /** 트리머의 현재 구간 값 (드래그로 갱신) */ + manualSegment: Segment; +} + +/** 콘솔 로그 payload. [L9] */ +export interface SelectionResult { + start: number; + end: number; + mode: SelectionMode; +} + +/** AI 추천 사유 시드 (Figma 5개 구간 사유). */ +const HIGHLIGHT_REASONS = [ + "움직임·밝기 지속", + "장면 변화 풍부", + "조회수 예측 상위", + "색감·구도 안정형", + "동작 다이나믹", +] as const; + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +/** + * AI 하이라이트 추천을 제공할지 판정한다. 영상 길이가 5초를 초과할 때만 true. [L1] + * 정확히 5초 및 그 이하이면 false (최소 구간 길이를 확보할 수 없음). + */ +export const shouldOfferHighlight = (duration: number): boolean => + duration > SEGMENT_MIN_SEC; + +/** + * 시작 핸들을 newStart로 옮길 때의 clamp 결과. 끝은 고정. [L2·L3·L4] + * - 끝-5초보다 뒤로 못 감(최소 길이), 끝-30초보다 앞으로 못 감(최대 길이), 0 미만 불가(경계). + * (하한이 0이므로 duration 상한은 시작 핸들에 불필요 — 끝 핸들만 duration을 받는다.) + */ +export const adjustStartHandle = ( + segment: Segment, + newStart: number, +): Segment => { + const min = Math.max(0, segment.end - SEGMENT_MAX_SEC); + const max = segment.end - SEGMENT_MIN_SEC; + return { start: clamp(newStart, min, max), end: segment.end }; +}; + +/** + * 끝 핸들을 newEnd로 옮길 때의 clamp 결과. 시작은 고정. [L2·L3·L4] + * - 시작+5초보다 앞으로 못 감(최소 길이), 시작+30초보다 뒤로 못 감(최대 길이), duration 초과 불가(경계). + */ +export const adjustEndHandle = ( + segment: Segment, + newEnd: number, + duration: number, +): Segment => { + const min = segment.start + SEGMENT_MIN_SEC; + const max = Math.min(duration, segment.start + SEGMENT_MAX_SEC); + return { start: segment.start, end: clamp(newEnd, min, max) }; +}; + +/** + * 임의 구간을 5~30초 길이·[0, duration] 범위로 정규화한다. [L2·L3·L4·L8] + * 목업 구간 생성·초기 구간 계산에서 유효성을 보장한다. + */ +export const clampSegment = (segment: Segment, duration: number): Segment => { + const length = Math.min( + clamp(segment.end - segment.start, SEGMENT_MIN_SEC, SEGMENT_MAX_SEC), + duration, + ); + const start = clamp(segment.start, 0, duration - length); + return { start, end: start + length }; +}; + +/** + * 밴드 본체 드래그 — 길이를 유지한 채 구간을 delta만큼 이동하고 경계에서 정지한다. [S7] + */ +export const moveSegment = ( + segment: Segment, + delta: number, + duration: number, +): Segment => { + const length = segment.end - segment.start; + const start = clamp(segment.start + delta, 0, duration - length); + return { start, end: start + length }; +}; + +/** 트리머 초기 구간 — 진입 시 표시용 기본 밴드(선택은 아님, 추정 5). */ +const initialManualSegment = (duration: number): Segment => + clampSegment( + { start: duration * 0.2, end: duration * 0.2 + SEGMENT_MAX_SEC }, + duration, + ); + +/** 초기 선택 상태 — 미선택(mode=null). [L6·추정 5] */ +export const createInitialSelection = ( + duration: number, +): HighlightSelectionState => ({ + mode: null, + selectedAi: null, + manualSegment: initialManualSegment(duration), +}); + +/** AI 추천 구간을 선택한다 — 직접 지정 선택을 해제한다(상호 배타). [L5] */ +export const selectAi = ( + state: HighlightSelectionState, + suggestion: HighlightSuggestion, +): HighlightSelectionState => ({ + ...state, + mode: "ai", + selectedAi: suggestion, +}); + +/** 직접 구간을 지정/갱신한다 — AI 추천 선택을 해제한다(상호 배타). [L5] */ +export const selectManual = ( + state: HighlightSelectionState, + segment: Segment, +): HighlightSelectionState => ({ + ...state, + mode: "manual", + selectedAi: null, + manualSegment: segment, +}); + +/** 현재 선택된 구간 — 미선택이면 null. [L6·L9] */ +export const getSelectedSegment = ( + state: HighlightSelectionState, +): Segment | null => { + if (state.mode === "ai") { + return state.selectedAi + ? { start: state.selectedAi.start, end: state.selectedAi.end } + : null; + } + if (state.mode === "manual") { + return { start: state.manualSegment.start, end: state.manualSegment.end }; + } + return null; +}; + +/** 다음 단계로 진행 가능한지 — 구간이 하나라도 선택되면 true. [L6] */ +export const canProceedToNextStep = ( + state: HighlightSelectionState, +): boolean => getSelectedSegment(state) !== null; + +/** 초를 m:ss 형식으로 포맷한다 (예: 3 → "0:03", 75 → "1:15"). [L7] */ +export const formatTimecode = (seconds: number): string => { + const total = Math.floor(seconds); + const minutes = Math.floor(total / 60); + const secs = total % 60; + return `${minutes}:${String(secs).padStart(2, "0")}`; +}; + +/** + * 목업 AI 추천 구간 3~5개를 생성한다. [L8] + * 각 구간은 5~30초 길이·[0, duration] 범위를 만족한다(clampSegment로 보장). + * 개수는 영상 길이에 따라 3(짧음)~5(김)로 가변. + */ +export const buildMockHighlights = (duration: number): HighlightSuggestion[] => { + const count = Math.max(3, Math.min(5, Math.floor(duration / 10) + 1)); + const spacing = duration / count; + const desiredLength = clamp( + Math.round(spacing), + SEGMENT_MIN_SEC, + SEGMENT_MAX_SEC, + ); + + return Array.from({ length: count }, (_, i) => { + const seed: Segment = { + start: spacing * i, + end: spacing * i + desiredLength, + }; + const seg = clampSegment(seed, duration); + return { + id: `mock-${i + 1}`, + reason: HIGHLIGHT_REASONS[i % HIGHLIGHT_REASONS.length], + start: seg.start, + end: seg.end, + }; + }); +}; + +/** 선택 결과 payload — 콘솔 로그용. 미선택이면 null. [L9] */ +export const toSelectionResult = ( + state: HighlightSelectionState, +): SelectionResult | null => { + const segment = getSelectedSegment(state); + if (!segment || !state.mode) return null; + return { start: segment.start, end: segment.end, mode: state.mode }; +}; diff --git a/apps/web/src/features/upload/model/use-highlight-selection.ts b/apps/web/src/features/upload/model/use-highlight-selection.ts new file mode 100644 index 00000000..ebdf4727 --- /dev/null +++ b/apps/web/src/features/upload/model/use-highlight-selection.ts @@ -0,0 +1,38 @@ +import { useCallback, useState } from "react"; +import { + createInitialSelection, + type HighlightSelectionState, + type HighlightSuggestion, + type Segment, + selectAi, + selectManual, +} from "./highlight-selection"; + +/** + * 하이라이트 선택 상태 훅 — 순수 reducer(highlight-selection)를 감싼다. + * 플랫폼 API를 참조하지 않아 RN 재사용 대상(useState만 사용). + * duration이 바뀌면(다른 영상) 렌더 중 선택을 초기화한다 — effect가 아닌 "이전 값 비교" 패턴. + */ +export const useHighlightSelection = (duration: number) => { + const [state, setState] = useState(() => + createInitialSelection(duration), + ); + const [prevDuration, setPrevDuration] = useState(duration); + + if (duration !== prevDuration) { + setPrevDuration(duration); + setState(createInitialSelection(duration)); + } + + /** AI 추천 구간을 선택한다(직접 지정 해제). */ + const chooseAi = useCallback((suggestion: HighlightSuggestion) => { + setState((prev) => selectAi(prev, suggestion)); + }, []); + + /** 직접 구간을 지정/갱신한다(AI 추천 해제). */ + const chooseManual = useCallback((segment: Segment) => { + setState((prev) => selectManual(prev, segment)); + }, []); + + return { state, chooseAi, chooseManual }; +}; diff --git a/apps/web/src/features/upload/ui/HighlightStep.tsx b/apps/web/src/features/upload/ui/HighlightStep.tsx new file mode 100644 index 00000000..acc45e6c --- /dev/null +++ b/apps/web/src/features/upload/ui/HighlightStep.tsx @@ -0,0 +1,115 @@ +import { useMemo, useRef, useState } from "react"; +import { ModalCard } from "@fillmap/ui-web"; +import { + buildMockHighlights, + canProceedToNextStep, + formatTimecode, + getSelectedSegment, + type HighlightSuggestion, + type Segment, + toSelectionResult, +} from "@/features/upload/model/highlight-selection"; +import { useHighlightSelection } from "@/features/upload/model/use-highlight-selection"; +import { SegmentList } from "./SegmentList"; +import { SegmentTrimmer } from "./SegmentTrimmer"; +import { VideoPreview, type VideoPreviewHandle } from "./VideoPreview"; + +interface HighlightStepProps { + /** 미리보기 objectURL (실제 재생 소스) */ + objectUrl: string | null; + /** 실측 영상 길이(초) */ + duration: number; + /** 모달 전체 닫기 (✕) */ + onClose: () => void; +} + +const STEP_DESCRIPTION = + "AI가 추천한 최적 구간을 확인하고 선택하세요 · 2/4 단계"; + +/** + * 2단계 "AI 하이라이트 추천" 화면 본체. [S2~S11] + * 미리보기 + AI 추천 리스트(목업) + 직접 구간 트리머 + 선택 요약을 ModalCard 안에 조립한다. + * 선택 상태·트리머 드래그는 모달 로컬(useHighlightSelection) — 전역 스토어에 추가하지 않는다. + * "다음 단계"는 선택 결과를 콘솔에 로그할 뿐 실제 화면 이동은 없다(범위 밖). + */ +export const HighlightStep = ({ + objectUrl, + duration, + onClose, +}: HighlightStepProps) => { + const suggestions = useMemo( + () => buildMockHighlights(duration), + [duration], + ); + const { state, chooseAi, chooseManual } = useHighlightSelection(duration); + const previewRef = useRef(null); + const [playhead, setPlayhead] = useState(null); + + const selectedSegment = getSelectedSegment(state); + const selectedAiId = state.mode === "ai" ? state.selectedAi?.id ?? null : null; + + const playSegment = (segment: Segment) => { + previewRef.current?.playSegment(segment); + }; + + const handlePlaySuggestion = (suggestion: HighlightSuggestion) => { + playSegment({ start: suggestion.start, end: suggestion.end }); + }; + + const handleConfirm = () => { + const result = toSelectionResult(state); + if (result) { + // 실제 AI 분석·다음 화면 전환은 범위 밖 — 선택 결과 로그가 임시 완료 지점 (S11·L9) + console.log("[MSG-118] 선택 구간", result); + } + }; + + return ( + + + +
+ AI 추천 구간 + +
+ +
+ 직접 구간 지정 + +
+ +
+ 선택한 구간 + + {selectedSegment + ? `${formatTimecode(selectedSegment.start)} – ${formatTimecode( + selectedSegment.end, + )} · ${Math.round(selectedSegment.end - selectedSegment.start)}초` + : "구간을 선택하세요"} + +
+
+ ); +}; diff --git a/apps/web/src/features/upload/ui/SegmentList.tsx b/apps/web/src/features/upload/ui/SegmentList.tsx new file mode 100644 index 00000000..d409f0a8 --- /dev/null +++ b/apps/web/src/features/upload/ui/SegmentList.tsx @@ -0,0 +1,32 @@ +import type { HighlightSuggestion } from "@/features/upload/model/highlight-selection"; +import { SegmentRow } from "./SegmentRow"; + +interface SegmentListProps { + suggestions: HighlightSuggestion[]; + /** 현재 선택된 추천 구간 id (직접 지정 선택 중이면 null) */ + selectedId: string | null; + onSelect: (suggestion: HighlightSuggestion) => void; + onPlay: (suggestion: HighlightSuggestion) => void; +} + +/** + * AI 추천 구간 리스트 — 3~5개 항목을 세로로 나열한다. [S4] + */ +export const SegmentList = ({ + suggestions, + selectedId, + onSelect, + onPlay, +}: SegmentListProps) => ( +
+ {suggestions.map((suggestion) => ( + onSelect(suggestion)} + onPlay={() => onPlay(suggestion)} + /> + ))} +
+); diff --git a/apps/web/src/features/upload/ui/SegmentRow.tsx b/apps/web/src/features/upload/ui/SegmentRow.tsx new file mode 100644 index 00000000..5cedc09e --- /dev/null +++ b/apps/web/src/features/upload/ui/SegmentRow.tsx @@ -0,0 +1,71 @@ +import { Film, Play } from "lucide-react"; +import { cn } from "@fillmap/ui-web"; +import { + formatTimecode, + type HighlightSuggestion, +} from "@/features/upload/model/highlight-selection"; + +interface SegmentRowProps { + suggestion: HighlightSuggestion; + /** 선택 강조 여부 */ + selected: boolean; + /** 항목 선택 */ + onSelect: () => void; + /** 구간 재생(선택과 무관) */ + onPlay: () => void; +} + +/** + * AI 추천 구간 1개 — 썸네일 placeholder + 시작 시각 + 사유 + 재생 버튼. [S4·S5·S6] + * 선택 시 primary 테두리 + 옅은 primary 배경으로 강조(추정 4). 재생은 선택을 바꾸지 않는다(추정 3). + */ +export const SegmentRow = ({ + suggestion, + selected, + onSelect, + onPlay, +}: SegmentRowProps) => ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + }} + className={cn( + "flex w-full cursor-pointer items-center gap-sm rounded-md border p-xs text-left transition-colors", + selected + ? "border-primary bg-primary/5" + : "border-border bg-surface-soft hover:border-primary/40", + )} + > + + + + + + + {formatTimecode(suggestion.start)} – {formatTimecode(suggestion.end)} + + + {suggestion.reason} + + + + +
+); diff --git a/apps/web/src/features/upload/ui/SegmentTrimmer.tsx b/apps/web/src/features/upload/ui/SegmentTrimmer.tsx new file mode 100644 index 00000000..7826e3d5 --- /dev/null +++ b/apps/web/src/features/upload/ui/SegmentTrimmer.tsx @@ -0,0 +1,194 @@ +import { + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + useRef, +} from "react"; +import { cn } from "@fillmap/ui-web"; +import { + adjustEndHandle, + adjustStartHandle, + formatTimecode, + moveSegment, + type Segment, +} from "@/features/upload/model/highlight-selection"; + +interface SegmentTrimmerProps { + /** 영상 전체 길이(초) — 트랙 = 0~duration */ + duration: number; + /** 현재 구간 */ + segment: Segment; + /** 직접 지정이 선택 상태인지 — 밴드 강조에 사용 */ + selected: boolean; + /** 재생 위치(초) — 표시하지 않으려면 null */ + playhead: number | null; + /** 구간 변경 통보 (드래그마다) */ + onChange: (segment: Segment) => void; +} + +type DragMode = "start" | "end" | "band"; + +const pct = (value: number, duration: number) => + duration > 0 ? (value / duration) * 100 : 0; + +/** 트랙 요소 좌표 → 시각(초). ref가 아닌 이벤트의 currentTarget(=트랙)을 측정한다. */ +const secFromTrack = ( + track: HTMLElement, + clientX: number, + duration: number, +): number => { + const rect = track.getBoundingClientRect(); + if (rect.width === 0) return 0; + return ((clientX - rect.left) / rect.width) * duration; +}; + +/** + * 직접 구간 지정 트리머 — 트랙 = 영상 전체, 양끝 핸들 + 밴드 이동 + playhead + 실시간 라벨. [S7] + * 모든 포인터 상호작용을 트랙에 직접 바인딩해 currentTarget(=트랙)으로 좌표를 측정한다. + * 이동 범위 clamp(5~30초·경계)는 전부 순수 함수에 위임한다(L2·L3·L4). + * 프레임 썸네일은 범위 밖 — 트랙 배경은 균일 톤 placeholder(플랜 참조). + */ +export const SegmentTrimmer = ({ + duration, + segment, + selected, + playhead, + onChange, +}: SegmentTrimmerProps) => { + // pointerdown 시점의 드래그 모드·기준값 (이벤트 핸들러 안에서만 접근 — 렌더 중 접근 아님) + const dragRef = useRef<{ + mode: DragMode; + pointerSec: number; + origin: Segment; + } | null>(null); + + // 핸들 근처 grab 허용 오차 — 트랙 폭의 4%를 초로 환산 + const grabTolerance = duration * 0.04; + + const handlePointerDown = (event: ReactPointerEvent) => { + event.preventDefault(); + const sec = secFromTrack(event.currentTarget, event.clientX, duration); + const distStart = Math.abs(sec - segment.start); + const distEnd = Math.abs(sec - segment.end); + + let mode: DragMode; + if (distStart <= grabTolerance && distStart <= distEnd) { + mode = "start"; + } else if (distEnd <= grabTolerance) { + mode = "end"; + } else if (sec > segment.start && sec < segment.end) { + mode = "band"; + } else { + mode = distStart < distEnd ? "start" : "end"; + } + + dragRef.current = { mode, pointerSec: sec, origin: segment }; + event.currentTarget.setPointerCapture(event.pointerId); + + // 핸들을 바로 집으면 그 지점으로 즉시 이동(밴드는 이동 없이 잡기만) + if (mode === "start") onChange(adjustStartHandle(segment, sec)); + else if (mode === "end") onChange(adjustEndHandle(segment, sec, duration)); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + const sec = secFromTrack(event.currentTarget, event.clientX, duration); + if (drag.mode === "start") { + onChange(adjustStartHandle(segment, sec)); + } else if (drag.mode === "end") { + onChange(adjustEndHandle(segment, sec, duration)); + } else { + onChange(moveSegment(drag.origin, sec - drag.pointerSec, duration)); + } + }; + + const handlePointerUp = (event: ReactPointerEvent) => { + dragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + // 키보드 1초 이동(접근성) — 경계 clamp 동일 적용. ref 접근 없음. + const handleStartKey = (event: ReactKeyboardEvent) => { + const step = + event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (step === 0) return; + event.preventDefault(); + onChange(adjustStartHandle(segment, segment.start + step)); + }; + + const handleEndKey = (event: ReactKeyboardEvent) => { + const step = + event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (step === 0) return; + event.preventDefault(); + onChange(adjustEndHandle(segment, segment.end + step, duration)); + }; + + const length = segment.end - segment.start; + + return ( +
+
+ {/* 선택 구간 밴드 */} +
+ + {/* 시작 핸들 (키보드 접근용 slider) */} +
+ + {/* 끝 핸들 (키보드 접근용 slider) */} +
+ + {/* 재생 위치(playhead) */} + {playhead !== null && ( +
+ )} +
+ + {/* 실시간 라벨 — 요약과 동일 소스(formatTimecode) */} + + {formatTimecode(segment.start)} – {formatTimecode(segment.end)} ·{" "} + {Math.round(length)}초 + +
+ ); +}; diff --git a/apps/web/src/features/upload/ui/UploadDropzone.tsx b/apps/web/src/features/upload/ui/UploadDropzone.tsx index 2731e175..fc7e991d 100644 --- a/apps/web/src/features/upload/ui/UploadDropzone.tsx +++ b/apps/web/src/features/upload/ui/UploadDropzone.tsx @@ -9,8 +9,12 @@ import { interface UploadDropzoneProps { /** 현재 선택된 파일명 (없으면 null) — 표시는 부모 state 기준 */ selectedName: string | null; - /** 유효한 파일이 선택되면 호출 (무효 파일은 거부되어 호출되지 않음) */ - onSelectFile: (file: UploadCandidate) => void; + /** + * 유효한 파일이 선택되면 호출 (무효 파일은 거부되어 호출되지 않음). + * 판정용 중립 candidate와 함께 원본 File을 전달한다 — duration 캡처·미리보기(MSG-118)는 + * 플랫폼 File이 필요하므로 UI 경계인 부모에서 받는다(candidate는 RN-safe 형태 유지). + */ + onSelectFile: (file: UploadCandidate, source: File) => void; } const CONSTRAINT_TEXT = "최대 60초 · MP4, MOV · 500MB 이하"; @@ -38,7 +42,7 @@ export const UploadDropzone = ({ return; } setRejected(false); - onSelectFile(candidate); + onSelectFile(candidate, file); }; const handleDrop = (event: DragEvent) => { diff --git a/apps/web/src/features/upload/ui/UploadModal.tsx b/apps/web/src/features/upload/ui/UploadModal.tsx index 7cb839ea..18cea72e 100644 --- a/apps/web/src/features/upload/ui/UploadModal.tsx +++ b/apps/web/src/features/upload/ui/UploadModal.tsx @@ -3,12 +3,15 @@ import { MapPin } from "lucide-react"; import { Dialog } from "radix-ui"; import { cn, Input, ModalCard } from "@fillmap/ui-web"; import { MOCK_CELLS } from "@/entities/cell"; +import { shouldOfferHighlight } from "@/features/upload/model/highlight-selection"; import { useUploadModalStore } from "@/features/upload/model/upload-modal-store"; import { canSubmitUpload, type UploadCandidate, } from "@/features/upload/model/upload-validation"; +import { HighlightStep } from "./HighlightStep"; import { UploadDropzone } from "./UploadDropzone"; +import { useVideoDuration } from "./use-video-duration"; const MODAL_SUBTITLE = "지금 위치의 격자에 순간을 기록하세요"; @@ -16,40 +19,66 @@ const MODAL_SUBTITLE = "지금 위치의 격자에 순간을 기록하세요"; const CURRENT_CELL = MOCK_CELLS.find((cell) => cell.id === "A-14"); const LOCATION_LABEL = `${CURRENT_CELL?.label ?? "현재 격자"} (현재 위치)`; -/** AI 안내 / 최종 확인 박스 — 정적 프레젠테이션 (AC8·AC9) */ +/** + * AI 안내 / 최종 확인 박스 — 정적 프레젠테이션 (AC8·AC9). + * onClick이 주어지면 클릭 가능한 카드(커서 pointer / hover 반응)로 렌더한다 — MSG-118 S1. + */ const InfoBox = ({ title, body, tone, + onClick, }: { title: string; body: ReactNode; tone: "soft" | "dark"; -}) => ( -
- - {title} - - - {body} - -
-); + onClick?: () => void; +}) => { + const content = ( + <> + + {title} + + + {body} + + + ); + + const base = cn( + "flex w-full flex-col gap-xxs rounded-md px-md py-sm text-left", + tone === "dark" ? "bg-foreground" : "bg-surface-soft", + ); + + if (onClick) { + return ( + + ); + } + + return
{content}
; +}; /** * 영상 업로드 모달 — Radix Dialog(오버레이·포털·포커스 트랩·Esc·scrim)로 ModalCard를 감싼다. @@ -62,11 +91,26 @@ export const UploadModal = () => { const closeModal = useUploadModalStore((s) => s.closeModal); const [title, setTitle] = useState(""); const [file, setFile] = useState(null); + // 원본 File — duration 캡처·미리보기용(플랫폼 경계). candidate와 별도로 보관 (MSG-118) + const [rawFile, setRawFile] = useState(null); + // 모달 내부 스텝 전환 — 위젯 경계를 넘지 않으므로 전역 스토어가 아닌 로컬 state (스펙 계획) + const [step, setStep] = useState<"select" | "highlight">("select"); + + const { duration, objectUrl } = useVideoDuration(rawFile); + // 5초 초과 영상에서만 AI 추천 카드/스텝을 제공한다 (L1·S1·S12) + const offerHighlight = duration !== null && shouldOfferHighlight(duration); - // 닫힐 때마다 입력을 초기화해 다시 열면 이전 제목·파일이 남지 않는다 (AC10) + const handleSelectFile = (candidate: UploadCandidate, source: File) => { + setFile(candidate); + setRawFile(source); + }; + + // 닫힐 때마다 입력을 초기화해 다시 열면 이전 제목·파일·스텝이 남지 않는다 (AC10) const close = () => { setTitle(""); setFile(null); + setRawFile(null); + setStep("select"); closeModal(); }; @@ -84,20 +128,27 @@ export const UploadModal = () => { className="fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[480px] -translate-x-1/2 -translate-y-1/2 overflow-y-auto outline-none" > 영상 업로드 - - + ) : ( + +
- - - -
+ {/* 5초 초과 영상이면 클릭 가능한 카드(→ 2단계), 아니면 정적 안내 유지 (S1·S12) */} + setStep("highlight") : undefined + } + /> + + +
+ )} diff --git a/apps/web/src/features/upload/ui/VideoPreview.tsx b/apps/web/src/features/upload/ui/VideoPreview.tsx new file mode 100644 index 00000000..dd9effa7 --- /dev/null +++ b/apps/web/src/features/upload/ui/VideoPreview.tsx @@ -0,0 +1,72 @@ +import { forwardRef, useImperativeHandle, useRef } from "react"; +import { Film } from "lucide-react"; +import { cn } from "@fillmap/ui-web"; +import type { Segment } from "@/features/upload/model/highlight-selection"; + +export interface VideoPreviewHandle { + /** 구간 시작으로 seek 후 자동 재생, 구간 끝에서 정지. (MSG-118 추정 3) */ + playSegment: (segment: Segment) => void; +} + +interface VideoPreviewProps { + /** 미리보기 objectURL — 없으면 플레이스홀더 표시 */ + objectUrl: string | null; + /** 재생 위치(초) 변경 통보 — 트리머 playhead 갱신용 */ + onTimeUpdate?: (currentTime: number) => void; + className?: string; +} + +/** + * 선택 영상 미리보기 (플랫폼
{/* 5초 초과 영상이면 클릭 가능한 카드(→ 2단계), 아니면 정적 안내 유지 (S1·S12) */} + {/* 메타데이터 로드 실패 시 duration이 영구히 null로 남지 않고 원인을 안내한다 */} setStep("highlight") : undefined } diff --git a/apps/web/src/features/upload/ui/use-video-duration.ts b/apps/web/src/features/upload/ui/use-video-duration.ts index ac2c9569..2ccc0b8e 100644 --- a/apps/web/src/features/upload/ui/use-video-duration.ts +++ b/apps/web/src/features/upload/ui/use-video-duration.ts @@ -5,9 +5,11 @@ interface VideoMeta { duration: number | null; /** 미리보기
); }, diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index da0f475b..5acd4027 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -25,3 +25,4 @@ | 2026-07-20 | MSG-118 | 결정: `UploadDropzone.onSelectFile`에 원본 `File` 인자를 추가(candidate와 별도로 전달) — 스펙의 "UploadDropzone 변경 없음" 대신 스펙 리스크 항목의 "duration 캡처 경로 신규 추가"를 채택 | duration 실측(loadedmetadata)·미리보기(createObjectURL)가 모두 플랫폼 File을 요구하는데 기존 candidate는 RN-safe `{name,size}`뿐. candidate 타입을 오염시키지 않기 위해 File을 두 번째 인자로 UI 경계(부모)에 전달하고 판정은 순수 함수에 위임 | | 2026-07-20 | MSG-118 | 결정: `use-video-duration`/`use-highlight-selection`의 상태 초기화를 effect가 아닌 "이전 값 비교(setState during render)" 패턴으로 구현 | 레포의 `react-hooks` flat-recommended가 React 19 컴파일러 규칙(`set-state-in-effect`)을 error로 강제 — effect 동기 setState가 금지됨. objectURL 생성/revoke만 effect에 남기고 상태는 loadedmetadata 콜백(비동기)에서만 세팅해 규칙을 준수하면서 File 교체 시 stale 값 노출을 막음 | | 2026-07-20 | MSG-118 | 결정: SegmentTrimmer의 모든 포인터 상호작용을 트랙 요소에 직접 바인딩하고 `event.currentTarget`으로 좌표 측정(핸들별 커링 핸들러·trackRef 헬퍼 제거) | `react-hooks` `refs` 규칙이 커링된 핸들러 팩토리 안의 ref 접근을 "렌더 중 ref 접근"으로 오판. 좌표 측정을 ref 대신 이벤트의 currentTarget(=트랙)에서 얻고 드래그 상태만 이벤트 핸들러 내부 ref로 관리해 규칙을 충족 | +| 2026-07-21 | MSG-119 | 결정(스펙 해석): `BlurStep`이 `toBlurConfirmResult`로 확인 payload를 만들고 실제 `console.log`는 부모(`UploadModal`)가 주입한 `onConfirm(result)`이 수행하도록 분리 | 스펙이 BlurStep prop을 `onConfirm(콘솔 로그)`로, UploadModal 배선을 `onConfirm={…콘솔 로그}`로 동시에 기술해 로그 위치가 모호. 목업 감지 데이터·payload 조립은 BlurStep(도메인 소유)에 두고 "확인 시 무엇을 하는가"(임시 완료 지점=로그, 이후 4/4 전환)는 부모가 결정하도록 위임 — MSG-118 HighlightStep의 `onNext` 위임 패턴과 정합 | From fbf7cc1ffef508d9a1e0d88db2beab26828c8506 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 13:49:49 +0900 Subject: [PATCH 058/281] =?UTF-8?q?MSG-119=20docs:=20README=EC=97=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=C2=B7?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=A0=84=EB=9E=B5=C2=B7=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e43c6c10..6b0f7c4f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,109 @@ -# FE -필맵 프론트엔드 레포입니다 +# FillMap FE + +필맵(FillMap) 프론트엔드 모노레포입니다. 웹(React 19 + Vite)과 디자인 시스템 패키지를 함께 관리하며, React Native 앱으로 확장을 고려한 구조입니다. + +## 프로젝트 구조 + +pnpm workspace 기반 모노레포입니다. + +| 경로 | 패키지 | 설명 | +|------|--------|------| +| `apps/web` | web | React 19 + Vite 웹 애플리케이션 (FSD 구조) | +| `packages/design-tokens` | @fillmap/design-tokens | 색상·타이포·간격 등 디자인 토큰 | +| `packages/tailwind-preset` | @fillmap/tailwind-preset | 토큰을 반영한 Tailwind 프리셋 | +| `packages/ui-web` | @fillmap/ui-web | 공통 UI 컴포넌트 (Storybook 포함) | + +## 시작하기 + +pnpm `10.17.1` 기준입니다. + +```bash +pnpm install # 의존성 설치 +pnpm dev # 웹 개발 서버 실행 +pnpm build # 웹 프로덕션 빌드 +pnpm lint # 린트 검사 +pnpm storybook # ui-web 스토리북 실행 +``` + +## 문서 + +| 문서 | 내용 | +|------|------| +| [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md) | 디자인 시스템 규칙 (6개조) | +| [DESIGN_SYSTEM_SPEC.md](DESIGN_SYSTEM_SPEC.md) | 디자인 시스템 구조 스펙 | +| [docs/FIGMA_WORKFLOW.md](docs/FIGMA_WORKFLOW.md) | 피그마 연동 워크플로 | +| [docs/TICKET_TEMPLATE.md](docs/TICKET_TEMPLATE.md) | 지라 티켓 작성 템플릿 | +| [docs/decisions](docs/decisions) | 의사결정 기록 (ADR) | + +## 브랜치 전략 + +Git Flow를 기반으로 하되, `main`(배포) / `develop`(기본 브랜치) / 작업 브랜치 3계층으로 운영합니다. 작업 브랜치는 `develop`에서 분기하고 PR도 `develop`으로 보냅니다. + +### 브랜치 네이밍 컨벤션 + +| 유형 | 형식 | 예시 | +|------|------|------| +| 기능 추가 | `feat/MSG-<번호>-<기능명>` | `feat/MSG-118-ai-highlight-recommendation-ui` | +| 버그 수정 | `fix/MSG-<번호>-<기능명>` | `fix/MSG-134-login-redirect` | +| 긴급 패치 | `hotfix/MSG-<번호>-<기능명>` | `hotfix/MSG-188-navbar-crash` | + +- 기능명에는 kebab-case 사용 +- 번호는 지라 이슈 키(MSG-xxx)와 연동 +- 브랜치 유형은 티켓의 주 목적 기준으로 3종만 사용합니다. 리팩토링·설정성 작업은 별도 브랜치 유형 없이 `feat/`로 진행하고, 커밋 prefix(`refactor`, `chore`)로 구분합니다. + +## 커밋 컨벤션 + +[Udacity Git Style Guide](https://udacity.github.io/git-styleguide/) 기반이며, 앞에 지라 이슈 키를 붙입니다. + +``` +<지라키> : <제목> +``` + +| prefix | 설명 | +|--------|------| +| `feat` | 새로운 기능 추가 | +| `fix` | 버그 수정 | +| `docs` | 문서 수정 (README 등) | +| `design` | UI/스타일 변경 | +| `refactor` | 기능 변경 없이 코드 리팩토링 | +| `test` | 테스트 코드 추가, 변경 | +| `chore` | 설정, 빌드, 패키지 등 작업 (프로덕션 코드 영향 없음) | +| `hotfix` | 배포 후 긴급 수정 | + +``` +MSG-118 feat: AI 하이라이트 자동 추천 구간 선택 UI 구현 +MSG-116 fix: 신고 제출 중복 클릭 방지 +MSG-116 chore: danger 버튼 대비율 이슈 결정 기록 +MSG-120 docs: README에 브랜치 전략 설명 추가 +``` + +## 코드 컨벤션 + +### 파일 및 폴더명 + +- 폴더, 훅·스토어·유틸 등 로직 파일: **kebab-case** (`cell-viewport.ts`, `use-map-shell.ts`) +- React 컴포넌트 파일: **PascalCase** (`MapHomePage.tsx`, `SearchBox.tsx`) + +### 코드 스니펫 + +| 항목 | 스니펫 | 설명 | +|------|--------|------| +| UI 컴포넌트 | `rfc` | 함수형 컴포넌트 | +| 유틸리티 함수 | `rafc` | 화살표 함수 형태의 유틸 함수 정의 | + +### 변수 및 함수 + +- 변수 네이밍: **camelCase**, Boolean 값은 `is` 접두사 사용 (`isActive`) +- 상수: **대문자 스네이크 케이스** (`API_BASE_URL`) +- 이벤트 핸들러: 화살표 함수, `handle + 명사 + 동사` 네이밍 (`handleUserClick`) + +### 타입 정의 + +- 객체 타입: `interface` +- enum 대용, 간단한 타입: `type` +- 타입명: **PascalCase** (`UserInfo`, `ButtonVariant`) + +### 스타일 + +- Tailwind + 디자인 토큰 사용 — 임의 색상·수치 하드코딩 대신 토큰 사용 ([docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md) 준수) +- 길이 단위는 rem 기준 From b1d09597a95160bcea3d3ec008688bc2c4c9d454 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 14:08:28 +0900 Subject: [PATCH 059/281] =?UTF-8?q?MSG-119=20docs:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20HighlightStep=20JSDoc=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=B0=8F=20canProceed=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4=20=EC=A1=B0=EA=B1=B4=20=EC=A3=BC=EC=84=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/features/upload/ui/HighlightStep.tsx | 3 ++- apps/web/src/features/upload/ui/UploadModal.tsx | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/upload/ui/HighlightStep.tsx b/apps/web/src/features/upload/ui/HighlightStep.tsx index 46ac1d70..21aef04f 100644 --- a/apps/web/src/features/upload/ui/HighlightStep.tsx +++ b/apps/web/src/features/upload/ui/HighlightStep.tsx @@ -31,7 +31,8 @@ const STEP_DESCRIPTION = * 2단계 "AI 하이라이트 추천" 화면 본체. [S2~S11] * 미리보기 + AI 추천 리스트(목업) + 직접 구간 트리머 + 선택 요약을 ModalCard 안에 조립한다. * 선택 상태·트리머 드래그는 모달 로컬(useHighlightSelection) — 전역 스토어에 추가하지 않는다. - * "다음 단계"는 선택 결과를 콘솔에 로그할 뿐 실제 화면 이동은 없다(범위 밖). + * "이 구간으로 다음 단계"를 누르면 블러 확인(3/4) 스텝으로 전환한다(MSG-119 S6) — + * 선택 결과 payload 전달은 범위 밖. */ export const HighlightStep = ({ objectUrl, diff --git a/apps/web/src/features/upload/ui/UploadModal.tsx b/apps/web/src/features/upload/ui/UploadModal.tsx index cc42ef86..04627ebe 100644 --- a/apps/web/src/features/upload/ui/UploadModal.tsx +++ b/apps/web/src/features/upload/ui/UploadModal.tsx @@ -83,6 +83,8 @@ export const UploadModal = () => { const { duration, objectUrl, error: videoLoadError } = useVideoDuration(rawFile); // "다음" 활성 조건 = 유효 파일 && 메타데이터 로드 완료(duration 확정) && 로드 실패 아님 (Q1·S2·S7) + // 현재 훅 구현에선 error면 duration이 항상 null이라 !videoLoadError가 중복이지만, + // 훅 불변식이 바뀌어도 로드 실패 시 진행을 막도록 방어적으로 유지한다. const canProceed = canSubmitUpload(file) && duration !== null && !videoLoadError; From 0857d4e42058b20ca67c3a5289d74c0bbfeee6cc Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 15:24:42 +0900 Subject: [PATCH 060/281] =?UTF-8?q?MSG-120=20feat:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/model/blur-detection.test.ts | 13 ++ .../features/upload/model/blur-detection.ts | 6 + .../upload/model/highlight-selection.test.ts | 22 +++ .../upload/model/highlight-selection.ts | 11 ++ .../features/upload/model/upload-wizard.ts | 4 +- .../src/features/upload/ui/HighlightStep.tsx | 14 +- .../src/features/upload/ui/PreviewStep.tsx | 130 ++++++++++++++++++ .../src/features/upload/ui/UploadModal.tsx | 35 ++++- docs/decisions/DECISIONS.md | 1 + 9 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/features/upload/ui/PreviewStep.tsx diff --git a/apps/web/src/features/upload/model/blur-detection.test.ts b/apps/web/src/features/upload/model/blur-detection.test.ts index 208b9ff7..93e11c13 100644 --- a/apps/web/src/features/upload/model/blur-detection.test.ts +++ b/apps/web/src/features/upload/model/blur-detection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildMockBlurRegions, formatBlurCompletion, + formatBlurCount, summarizeBlurRegions, toBlurConfirmResult, } from "./blur-detection"; @@ -74,6 +75,18 @@ describe("formatBlurCompletion", () => { }); }); +describe("formatBlurCount", () => { + // L2 (MSG-120): "{count}개 처리됨" — 0도 항목을 숨기지 않고 "0개 처리됨"을 반환한다 + it("처리 개수를 'N개 처리됨' 형식으로 반환한다", () => { + expect(formatBlurCount(2)).toBe("2개 처리됨"); + expect(formatBlurCount(1)).toBe("1개 처리됨"); + }); + + it("0개도 항목을 숨기지 않고 '0개 처리됨'을 반환한다", () => { + expect(formatBlurCount(0)).toBe("0개 처리됨"); + }); +}); + describe("toBlurConfirmResult", () => { // L7: 감지 요약(얼굴 수·번호판 수)과 영상 길이를 담은 콘솔 로그 payload 객체 (Q4) it("감지 요약과 영상 길이(초)를 담은 payload를 반환한다", () => { diff --git a/apps/web/src/features/upload/model/blur-detection.ts b/apps/web/src/features/upload/model/blur-detection.ts index a502cdc7..b209c6af 100644 --- a/apps/web/src/features/upload/model/blur-detection.ts +++ b/apps/web/src/features/upload/model/blur-detection.ts @@ -78,6 +78,12 @@ export const summarizeBlurRegions = (regions: BlurRegion[]): BlurSummary => ({ export const formatBlurCompletion = (summary: BlurSummary): string => `AI 자동 블러 완료 — 얼굴 ${summary.faces}개, 번호판 ${summary.plates}개를 자동으로 가렸어요`; +/** + * 4/4 미리보기 블러 카드용 처리 개수 라벨. [MSG-120 L2] + * "{count}개 처리됨" — 0도 항목을 숨기지 않고 "0개 처리됨"으로 표기한다(처리 개수 0 표시). + */ +export const formatBlurCount = (count: number): string => `${count}개 처리됨`; + /** 확인 결과 payload — 감지 요약과 영상 길이(초)를 담는다. [L7·Q4] */ export const toBlurConfirmResult = ( summary: BlurSummary, diff --git a/apps/web/src/features/upload/model/highlight-selection.test.ts b/apps/web/src/features/upload/model/highlight-selection.test.ts index 2e6edcec..b0caa9b0 100644 --- a/apps/web/src/features/upload/model/highlight-selection.test.ts +++ b/apps/web/src/features/upload/model/highlight-selection.test.ts @@ -6,6 +6,7 @@ import { canProceedToNextStep, clampSegment, createInitialSelection, + formatSelectionRange, formatTimecode, getSelectedSegment, moveSegment, @@ -205,6 +206,27 @@ describe("toSelectionResult", () => { }); }); +describe("formatSelectionRange", () => { + // L1 (MSG-120): 초 단위 "s" 표기 + " – " 구분자, 소수 최대 1자리, 정수는 소수점 생략 + it("소수 구간을 소수 1자리 + 's' 표기로 ' – ' 구분해 포맷한다", () => { + expect(formatSelectionRange({ start: 1.2, end: 6.2, mode: "ai" })).toBe( + "1.2s – 6.2s", + ); + }); + + it("정수 구간은 소수점 없이 's'로 표기한다", () => { + expect(formatSelectionRange({ start: 6, end: 12, mode: "ai" })).toBe( + "6s – 12s", + ); + }); + + it("소수 둘째 자리 이하는 최대 1자리로 반올림한다", () => { + expect( + formatSelectionRange({ start: 6.25, end: 12.04, mode: "manual" }), + ).toBe("6.3s – 12s"); + }); +}); + describe("clampSegment / moveSegment — 트리머 보조", () => { it("clampSegment는 길이를 5~30초와 [0, duration] 범위로 정규화한다", () => { // 너무 짧은 구간은 최소 5초로 확장 diff --git a/apps/web/src/features/upload/model/highlight-selection.ts b/apps/web/src/features/upload/model/highlight-selection.ts index 01799274..d3486ac1 100644 --- a/apps/web/src/features/upload/model/highlight-selection.ts +++ b/apps/web/src/features/upload/model/highlight-selection.ts @@ -218,3 +218,14 @@ export const toSelectionResult = ( if (!segment || !state.mode) return null; return { start: segment.start, end: segment.end, mode: state.mode }; }; + +/** 초를 "Ns" 표기로 — 소수 최대 1자리, 정수는 소수점 생략(6→"6s", 6.25→"6.3s"). (MSG-120 Q1) */ +const formatSeconds = (value: number): string => `${Math.round(value * 10) / 10}s`; + +/** + * 4/4 미리보기 하이라이트 카드용 구간 라벨. [MSG-120 L1] + * 초 단위 "s" 표기를 " – "로 이은 range 문자열(예: {start:1.2,end:6.2} → "1.2s – 6.2s"). + * 2/4·3/4의 formatTimecode(m:ss)와 달리 Figma 4/4 예시대로 "Ns" 표기를 쓴다(Q1). + */ +export const formatSelectionRange = (result: SelectionResult): string => + `${formatSeconds(result.start)} – ${formatSeconds(result.end)}`; diff --git a/apps/web/src/features/upload/model/upload-wizard.ts b/apps/web/src/features/upload/model/upload-wizard.ts index ed18e8f2..a517175b 100644 --- a/apps/web/src/features/upload/model/upload-wizard.ts +++ b/apps/web/src/features/upload/model/upload-wizard.ts @@ -7,8 +7,8 @@ import { shouldOfferHighlight } from "./highlight-selection"; -/** 업로드 위저드 스텝 — 정보 입력 → (하이라이트) → 블러 확인. */ -export type UploadStep = "select" | "highlight" | "blur"; +/** 업로드 위저드 스텝 — 정보 입력 → (하이라이트) → 블러 확인 → 미리보기(4/4). */ +export type UploadStep = "select" | "highlight" | "blur" | "preview"; /** * 현재 스텝과 영상 길이로 다음 스텝을 판정한다. [L1·L2] diff --git a/apps/web/src/features/upload/ui/HighlightStep.tsx b/apps/web/src/features/upload/ui/HighlightStep.tsx index 21aef04f..c4350d7b 100644 --- a/apps/web/src/features/upload/ui/HighlightStep.tsx +++ b/apps/web/src/features/upload/ui/HighlightStep.tsx @@ -7,6 +7,8 @@ import { getSelectedSegment, type HighlightSuggestion, type Segment, + type SelectionResult, + toSelectionResult, } from "@/features/upload/model/highlight-selection"; import { useHighlightSelection } from "@/features/upload/model/use-highlight-selection"; import { SegmentList } from "./SegmentList"; @@ -20,8 +22,8 @@ interface HighlightStepProps { duration: number; /** 모달 전체 닫기 (✕) */ onClose: () => void; - /** 다음 단계(블러 확인)로 전환 — 선택 결과 전달은 4/4 최종 화면 티켓 소관 (MSG-119 S6) */ - onNext: () => void; + /** 다음 단계(블러 확인)로 전환 — 선택 결과(SelectionResult|null)를 상위로 전달 (MSG-120 S11, MSG-118 배선 완성) */ + onNext: (result: SelectionResult | null) => void; } const STEP_DESCRIPTION = @@ -31,8 +33,8 @@ const STEP_DESCRIPTION = * 2단계 "AI 하이라이트 추천" 화면 본체. [S2~S11] * 미리보기 + AI 추천 리스트(목업) + 직접 구간 트리머 + 선택 요약을 ModalCard 안에 조립한다. * 선택 상태·트리머 드래그는 모달 로컬(useHighlightSelection) — 전역 스토어에 추가하지 않는다. - * "이 구간으로 다음 단계"를 누르면 블러 확인(3/4) 스텝으로 전환한다(MSG-119 S6) — - * 선택 결과 payload 전달은 범위 밖. + * "이 구간으로 다음 단계"를 누르면 블러 확인(3/4) 스텝으로 전환하며(MSG-119 S6), + * 선택 결과(toSelectionResult)를 상위로 전달해 4/4 미리보기가 사용한다(MSG-120 S11). */ export const HighlightStep = ({ objectUrl, @@ -60,9 +62,9 @@ export const HighlightStep = ({ }; // "이 구간으로 다음 단계" → 블러 확인(3/4)으로 전환 (MSG-119 S6, MSG-118 콘솔 로그 대체). - // 선택 결과 payload 전달은 이번 티켓 범위 아님 — 스텝 전환만 수행한다. + // 선택 결과(SelectionResult|null)를 상위로 전달 — 4/4 미리보기 하이라이트 카드가 사용한다 (MSG-120 S11). const handleConfirm = () => { - onNext(); + onNext(toSelectionResult(state)); }; return ( diff --git a/apps/web/src/features/upload/ui/PreviewStep.tsx b/apps/web/src/features/upload/ui/PreviewStep.tsx new file mode 100644 index 00000000..5977a81b --- /dev/null +++ b/apps/web/src/features/upload/ui/PreviewStep.tsx @@ -0,0 +1,130 @@ +import { useMemo } from "react"; +import { MapPin, ShieldCheck } from "lucide-react"; +import { ModalCard } from "@fillmap/ui-web"; +import { + buildMockBlurRegions, + formatBlurCount, + summarizeBlurRegions, +} from "@/features/upload/model/blur-detection"; +import { + formatSelectionRange, + type SelectionResult, +} from "@/features/upload/model/highlight-selection"; +import { VideoPreview } from "./VideoPreview"; + +interface PreviewStepProps { + /** 미리보기 objectURL (실제 표시 소스) */ + objectUrl: string | null; + /** 2단계 하이라이트 선택 결과 — null이면 하이라이트 카드를 렌더하지 않는다(5초 이하 건너뜀) */ + highlightSelection: SelectionResult | null; + /** 위치 카드 라벨 — 1단계에서 태그된 격자(A-14)에서 파생 (Q3) */ + locationLabel: string; + /** 지금 게시하기(=위저드 닫기, 게시 API는 목업) */ + onPublish: () => void; + /** 이전 단계(블러 확인 3/4)로 돌아가기 */ + onBack: () => void; + /** 모달 전체 닫기 (✕) */ + onClose: () => void; +} + +const STEP_DESCRIPTION = "4/4 단계 · 최종 확인"; +// 하이라이트 카드 선택 근거 — Figma 정적 문구 (Q2, AI reason 리프팅 대신 고정 표시) +const HIGHLIGHT_REASON = "조회수·움직임 기반 최적 5초 구간이 선택되었습니다"; + +/** + * 4단계 "업로드 미리보기" 화면 본체. [S1~S11] + * 선택 영상 미리보기 + AI 하이라이트 선택 구간 + 자동 블러 결과 + 위치 태그를 한 화면에서 + * 최종 확인하고 "지금 게시하기"로 게시(목업)한다. ModalCard 쉘 안에 카드를 조립한다. + * view-only — 미리보기는 재생/컨트롤 없이 정지 표시(Q5), "블러 결과 수동 조정" 버튼은 렌더하지 않는다(S7). + * 블러 요약은 BlurStep과 동일한 결정적 목업(buildMockBlurRegions)으로 재계산한다(Q6). + */ +export const PreviewStep = ({ + objectUrl, + highlightSelection, + locationLabel, + onPublish, + onBack, + onClose, +}: PreviewStepProps) => { + const summary = useMemo( + () => summarizeBlurRegions(buildMockBlurRegions()), + [], + ); + + return ( + + + + {/* 하이라이트 카드 — 선택 결과가 있을 때만(5초 초과 흐름). 어두운 강조 (InfoBox tone="dark" 준용) [S3·S4·S11] */} + {highlightSelection && ( +
+
+ + ✦ AI 하이라이트 구간 + + + {formatSelectionRange(highlightSelection)} + +
+ + {HIGHLIGHT_REASON} + +
+ )} + + {/* 블러 카드 — 완료 배지 + 얼굴·번호판 처리 개수 2열. 수동 조정 버튼은 렌더하지 않는다 [S5·S7] */} +
+
+ + + 개인정보 자동 블러 + + + 완료 + +
+
+
+ 얼굴 + + {formatBlurCount(summary.faces)} + +
+
+ 번호판 + + {formatBlurCount(summary.plates)} + +
+
+
+ + {/* 위치 카드 — 1단계에서 태그된 격자(A-14) 파생 라벨 [S6] */} +
+ +
+ 위치 + + {locationLabel} + +
+
+ + {/* "이전 단계로" — Figma는 버튼 아래 밑줄 링크지만 ModalCard children은 버튼 행 위에 렌더된다(Q4) [S9] */} + +
+ ); +}; diff --git a/apps/web/src/features/upload/ui/UploadModal.tsx b/apps/web/src/features/upload/ui/UploadModal.tsx index 04627ebe..e47c1995 100644 --- a/apps/web/src/features/upload/ui/UploadModal.tsx +++ b/apps/web/src/features/upload/ui/UploadModal.tsx @@ -4,6 +4,7 @@ import { Dialog } from "radix-ui"; import { cn, Input, ModalCard } from "@fillmap/ui-web"; import { MOCK_CELLS } from "@/entities/cell"; import { useUploadModalStore } from "@/features/upload/model/upload-modal-store"; +import type { SelectionResult } from "@/features/upload/model/highlight-selection"; import { getNextStep, type UploadStep, @@ -14,6 +15,7 @@ import { } from "@/features/upload/model/upload-validation"; import { BlurStep } from "./BlurStep"; import { HighlightStep } from "./HighlightStep"; +import { PreviewStep } from "./PreviewStep"; import { UploadDropzone } from "./UploadDropzone"; import { useVideoDuration } from "./use-video-duration"; @@ -22,6 +24,11 @@ const MODAL_SUBTITLE = "지금 위치의 격자에 순간을 기록하세요"; // 위치→격자 해석 로직은 이번 범위 아님 — mock 격자(A-14) 기반 정적 라벨 (Q6·AC7) const CURRENT_CELL = MOCK_CELLS.find((cell) => cell.id === "A-14"); const LOCATION_LABEL = `${CURRENT_CELL?.label ?? "현재 격자"} (현재 위치)`; +// 4/4 미리보기 위치 카드 — 태그된 셀(A-14)의 상세 위치 + 라벨 합성. +// Figma "합정동" 플레이스홀더 대신 태그한 셀 실제 데이터를 사용한다 (MSG-120 Q3·S6). +const PREVIEW_LOCATION_LABEL = CURRENT_CELL + ? `${CURRENT_CELL.location} · ${CURRENT_CELL.label}` + : "현재 격자"; /** * AI 안내 / 최종 확인 박스 — 정적 프레젠테이션 (AC8·AC9). @@ -79,6 +86,10 @@ export const UploadModal = () => { const [rawFile, setRawFile] = useState(null); // 모달 내부 스텝 전환 — 위젯 경계를 넘지 않으므로 전역 스토어가 아닌 로컬 state (스펙 계획) const [step, setStep] = useState("select"); + // 2단계 하이라이트 선택 결과를 4/4 미리보기로 상위 전달·보관 (MSG-120 S3·S11). + // 5초 이하 건너뜀 흐름·재오픈 시 null — 하이라이트 카드 미표시를 보장한다 (S4·S8). + const [highlightSelection, setHighlightSelection] = + useState(null); const { duration, objectUrl, error: videoLoadError } = useVideoDuration(rawFile); @@ -101,11 +112,13 @@ export const UploadModal = () => { }; // 닫힐 때마다 입력을 초기화해 다시 열면 이전 제목·파일·스텝이 남지 않는다 (AC10) + // 하이라이트 선택도 리셋 — 재오픈 잔존·5초 이하 새 영상의 하이라이트 카드 오표시 방지 (MSG-120 S4·S8) const close = () => { setTitle(""); setFile(null); setRawFile(null); setStep("select"); + setHighlightSelection(null); closeModal(); }; @@ -128,17 +141,29 @@ export const UploadModal = () => { objectUrl={objectUrl} duration={duration} onClose={close} - onNext={() => setStep("blur")} + onNext={(result) => { + // 선택 결과를 상위에 보관 후 블러 확인(3/4)으로 전환 (MSG-120 S3·S11) + setHighlightSelection(result); + setStep("blur"); + }} /> ) : step === "blur" && duration !== null ? ( - // 4/4 최종 화면은 다음 티켓 — 확인 결과 로그가 임시 완료 지점 (S13) - console.log("[MSG-119] 블러 확인", result) - } + // 확인 시 4/4 미리보기로 전환 (MSG-120 S1, MSG-119 콘솔 로그 대체). + // BlurStep 시그니처는 유지 — payload는 계속 생성되며 상위에서 미사용(고아 방지). + onConfirm={() => setStep("preview")} + /> + ) : step === "preview" && duration !== null ? ( + setStep("blur")} + onClose={close} /> ) : ( Date: Tue, 21 Jul 2026 16:15:35 +0900 Subject: [PATCH 061/281] =?UTF-8?q?MSG-163=20chore:=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=B3=B8=20=EB=8B=A8=EC=9D=BC=ED=99=94=20?= =?UTF-8?q?=E2=80=94=20=EC=BB=A4=EB=B0=8B=20prefix=C2=B7=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=ED=83=80=EC=9E=85=C2=B7PR=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=C2=B7Node=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README 커밋 prefix 표를 .husky/commit-msg 훅 목록과 1:1 일치시킴 (design·hotfix 제거, style·setting 추가) - ticket-to-spec 스킬 브랜치 타입을 README 전략(feat/fix/hotfix)과 통일 - PR 템플릿의 타 프로젝트 플레이스홀더(MM-XXX·[your-jira]) 정정, 체크리스트를 하네스 검증 게이트와 정합 - .nvmrc(26) + engines(>=24)로 Node 버전 고정, claude-review 워크플로에 concurrency 추가 --- .claude/skills/ticket-to-spec/SKILL.md | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 14 ++++++++------ .github/workflows/claude-review.yml | 5 +++++ .nvmrc | 1 + CLAUDE.md | 1 + README.md | 10 ++++++---- package.json | 3 +++ 7 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 .nvmrc diff --git a/.claude/skills/ticket-to-spec/SKILL.md b/.claude/skills/ticket-to-spec/SKILL.md index 3c1f66ab..14cef4d2 100644 --- a/.claude/skills/ticket-to-spec/SKILL.md +++ b/.claude/skills/ticket-to-spec/SKILL.md @@ -19,7 +19,7 @@ description: "지라 티켓(MSG-xxx) 텍스트 기획을 수용 기준 + 구현 - 검증 가능 = 통과/실패를 관찰로 판정할 수 있음. "지도가 잘 보인다"는 불가, "페이지 진입 시 지도가 서울 중심으로 렌더링된다"는 가능 - 로직 기준(→ vitest 테스트 대상)과 화면 기준(→ 브라우저 확인 대상)을 구분 표기한다 4. **구현 계획 수립**: 재사용할 컴포넌트, 새로 만들 로직(훅·스토어·스키마), 라우트, 승격 후보를 명시한다. -5. **브랜치 확인**: 현재 브랜치가 해당 티켓 브랜치(`타입/MSG-{번호}-{설명}`)인지 확인하고, 아니면 생성을 계획에 포함한다. 타입은 feat/fix/chore 중 기획 성격에 맞게. +5. **브랜치 확인**: 현재 브랜치가 해당 티켓 브랜치(`타입/MSG-{번호}-{설명}`)인지 확인하고, 아니면 생성을 계획에 포함한다. 타입은 README 브랜치 전략과 동일하게 feat/fix/hotfix 3종 중 선택 — 리팩토링·설정성 티켓도 브랜치는 `feat/`로 만들고 커밋 prefix(refactor, chore, setting)로 구분한다. ## 스펙 템플릿 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9f00d28e..b0ee488a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,17 +1,19 @@ ## 🎫 관련 티켓 -- Closes [MM-XXX](https://[your-jira].atlassian.net/browse/MM-XXX) +- Closes [MSG-XXX](https://soma17-msg.atlassian.net/browse/MSG-XXX) ## 📌 작업 내용 ## ✅ 체크리스트 -- [ ] 코드가 정상적으로 동작하는지 테스트 완료 -- [ ] 필요한 경우 문서를 업데이트했는지 확인 -- [ ] 코드 리뷰어가 이해할 수 있도록 설명을 추가했는지 확인 +- [ ] `pnpm lint` / `pnpm typecheck` / `pnpm --filter web test run` 통과 +- [ ] 수용 기준 검증 완료 (검증 리포트 요약을 아래에 첨부) +- [ ] 필요한 경우 문서(README, docs/) 업데이트 -## 📸 스크린샷 (선택) +## 🔍 검증 요약 + + -## 🚀 테스트 방법 +## 📸 스크린샷 (선택) ## 💡 추가 논의할 사항 diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 1dea1de2..e0387e06 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -4,6 +4,11 @@ on: pull_request: types: [opened, reopened, ready_for_review, synchronize] +# 같은 PR에 연속 push 시 낡은 diff 기준 리뷰가 중복 완주하지 않도록 이전 실행을 취소 +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: claude-review: # 드래프트 PR은 리뷰하지 않음 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..6f4247a6 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/CLAUDE.md b/CLAUDE.md index 5f96bbec..e455da1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,3 +15,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail | 2026-07-15 | 티켓 description 템플릿 추가, 스펙 변환 시 템플릿 구조 활용 | docs/TICKET_TEMPLATE.md, skills/ticket-to-spec | 티켓 작성 표준화로 스펙 승인 질문 최소화 | | 2026-07-15 | atlassian MCP 연결 — 티켓 번호만으로 지라 본문 조회 | skills/ticket-to-spec, skills/fillmap-page-dev | "MSG-xxx 진행해줘"만으로 파이프라인 시작 가능하게 | | 2026-07-15 | 수술적 변경 원칙 추가 (범위 밖 코드 불간섭, 고아 정리, 기존 죽은 코드는 보고만) | skills/page-implementation | 외부 코딩 가이드에서 하네스에 없던 원칙만 선별 흡수 | +| 2026-07-21 | 컨벤션 정본 단일화 — 커밋 prefix 표를 훅 기준으로(design·hotfix→style·setting), 브랜치 타입 feat/fix/hotfix로 통일, PR 템플릿 플레이스홀더 정정, Node 고정(.nvmrc·engines), 리뷰 CI concurrency | README.md, skills/ticket-to-spec, .github, .nvmrc | MSG-163 하네스 감사 — 문서·훅·스킬 3원 불일치로 README 준수 커밋이 훅에 거부되는 함정 제거 | diff --git a/README.md b/README.md index 6b0f7c4f..ddfddd34 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,15 @@ Git Flow를 기반으로 하되, `main`(배포) / `develop`(기본 브랜치) / | prefix | 설명 | |--------|------| | `feat` | 새로운 기능 추가 | -| `fix` | 버그 수정 | +| `fix` | 버그 수정 (`hotfix/` 브랜치의 커밋도 `fix` 사용) | | `docs` | 문서 수정 (README 등) | -| `design` | UI/스타일 변경 | +| `style` | UI/스타일 변경 (기능 변경 없음) | | `refactor` | 기능 변경 없이 코드 리팩토링 | | `test` | 테스트 코드 추가, 변경 | -| `chore` | 설정, 빌드, 패키지 등 작업 (프로덕션 코드 영향 없음) | -| `hotfix` | 배포 후 긴급 수정 | +| `chore` | 빌드, 패키지, 결정 기록 등 작업 (프로덕션 코드 영향 없음) | +| `setting` | 개발 환경·설정 파일 구성 (tsconfig, CI, 훅 등) | + +이 목록은 `.husky/commit-msg` 훅이 강제하는 목록과 1:1로 일치한다. prefix를 추가/삭제할 때는 이 표와 훅을 함께 수정한다. ``` MSG-118 feat: AI 하이라이트 자동 추천 구간 선택 UI 구현 diff --git a/package.json b/package.json index 9d7ecd80..fb4a3f2f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ }, "license": "ISC", "packageManager": "pnpm@10.17.1", + "engines": { + "node": ">=24" + }, "devDependencies": { "husky": "^9.1.7" } From 50a2ea52a452fbd8abec9ace9780d27a856bf45a Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 16:16:33 +0900 Subject: [PATCH 062/281] =?UTF-8?q?MSG-163=20setting:=20tsconfig=20strict?= =?UTF-8?q?=20=EB=AA=85=EC=8B=9C=20=EB=B0=8F=20index.html=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=A9=94=ED=83=80=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - strict는 TS 6.0부터 기본값이라 동작 변화 없음 — 다운그레이드·에디터 혼선 대비 명시 (Vite 팀 관행) - lang=en→ko: 한국어 서비스를 스크린리더가 영어 엔진으로 읽던 문제, title 스캐폴드 기본값(web) 교체 - 검증: tsc -b 오류 0, vitest 154/154 통과 --- CLAUDE.md | 1 + apps/web/index.html | 4 ++-- apps/web/tsconfig.app.json | 2 ++ apps/web/tsconfig.node.json | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e455da1f..51fe5006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,3 +16,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail | 2026-07-15 | atlassian MCP 연결 — 티켓 번호만으로 지라 본문 조회 | skills/ticket-to-spec, skills/fillmap-page-dev | "MSG-xxx 진행해줘"만으로 파이프라인 시작 가능하게 | | 2026-07-15 | 수술적 변경 원칙 추가 (범위 밖 코드 불간섭, 고아 정리, 기존 죽은 코드는 보고만) | skills/page-implementation | 외부 코딩 가이드에서 하네스에 없던 원칙만 선별 흡수 | | 2026-07-21 | 컨벤션 정본 단일화 — 커밋 prefix 표를 훅 기준으로(design·hotfix→style·setting), 브랜치 타입 feat/fix/hotfix로 통일, PR 템플릿 플레이스홀더 정정, Node 고정(.nvmrc·engines), 리뷰 CI concurrency | README.md, skills/ticket-to-spec, .github, .nvmrc | MSG-163 하네스 감사 — 문서·훅·스킬 3원 불일치로 README 준수 커밋이 훅에 거부되는 함정 제거 | +| 2026-07-21 | tsconfig strict 명시(TS6 기본값이나 다운그레이드 대비), index.html lang=ko·서비스명 title | apps/web | MSG-163 — 감사 재검증에서 "non-strict" 지적은 철회됐고(TS6 기본 strict) 명시만 보강, 문서 메타는 a11y 결함 | diff --git a/apps/web/index.html b/apps/web/index.html index 5e3836a6..8166b0d5 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,10 +1,10 @@ - + - web + FillMap — 필맵
diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index db16dfdd..72377b8a 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -1,6 +1,8 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + /* TS 6.0부터 strict가 기본값이지만, 다운그레이드·에디터 혼선 방지를 위해 명시 유지 */ + "strict": true, "target": "es2023", "lib": ["ES2023", "DOM"], "module": "esnext", diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json index 8455dcbc..26c494ef 100644 --- a/apps/web/tsconfig.node.json +++ b/apps/web/tsconfig.node.json @@ -1,6 +1,8 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + /* TS 6.0부터 strict가 기본값이지만, 다운그레이드·에디터 혼선 방지를 위해 명시 유지 */ + "strict": true, "target": "es2023", "lib": ["ES2023"], "types": ["node"], From 9a157b29f5dc2ff6501139e26146dab32861bd55 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 16:19:16 +0900 Subject: [PATCH 063/281] =?UTF-8?q?MSG-163=20setting:=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EB=A5=BC=20=EC=A0=84=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A1=9C=20=ED=99=95=EC=9E=A5=20=E2=80=94=20?= =?UTF-8?q?ui-web=20lint=20=EC=8B=A0=EC=84=A4=C2=B7=EB=A3=A8=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=C2=B7=EC=A3=BD=EC=9D=80=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui-web에 eslint flat config + lint 스크립트 신설 (컴포넌트 21개·스토리 20개가 lint 커버 0%였음) - 루트 lint를 재귀 실행으로 교체, typecheck·test 루트 스크립트 신설 — stories가 처음으로 strict typecheck에 포함 - design-tokens·tailwind-preset에 tsconfig + typecheck 추가 - 미사용 프로덕션 의존성 제거: axios·zod·react-hook-form·@hookform/resolvers·date-fns·class-variance-authority (전부 import 0 확인, shadcn은 globals.css에서 실사용이라 유지) - 죽은 코드 apps/web/src/lib/ 제거 (import 0곳, shadcn init 잔재 — cn은 @fillmap/ui-web 사용) - page-verification 스킬 명령을 루트 게이트로 교체 - 검증: lint·typecheck 4패키지 통과, vitest 151/151, build 통과 --- .claude/skills/page-verification/SKILL.md | 8 +- CLAUDE.md | 1 + apps/web/package.json | 6 - apps/web/src/lib/utils.test.ts | 20 --- apps/web/src/lib/utils.ts | 6 - package.json | 4 +- packages/design-tokens/package.json | 6 + packages/design-tokens/tsconfig.json | 16 ++ packages/tailwind-preset/package.json | 6 +- packages/tailwind-preset/tsconfig.json | 16 ++ packages/ui-web/eslint.config.js | 20 +++ packages/ui-web/package.json | 6 + pnpm-lock.yaml | 187 +++------------------- 13 files changed, 100 insertions(+), 202 deletions(-) delete mode 100644 apps/web/src/lib/utils.test.ts delete mode 100644 apps/web/src/lib/utils.ts create mode 100644 packages/design-tokens/tsconfig.json create mode 100644 packages/tailwind-preset/tsconfig.json create mode 100644 packages/ui-web/eslint.config.js diff --git a/.claude/skills/page-verification/SKILL.md b/.claude/skills/page-verification/SKILL.md index 02481511..d8ec4742 100644 --- a/.claude/skills/page-verification/SKILL.md +++ b/.claude/skills/page-verification/SKILL.md @@ -12,12 +12,12 @@ description: "구현된 페이지를 스펙의 수용 기준으로 검증하는 ### 1. 자동 검증 ``` -pnpm --filter web test run -pnpm --filter web typecheck -pnpm lint +pnpm test # apps/web vitest (run 모드) +pnpm typecheck # 전 패키지 — web(tsc -b) + ui-web(stories 포함 strict) + design-tokens + tailwind-preset +pnpm lint # 전 패키지 — apps/web + packages/ui-web ``` -셋 중 하나라도 실패하면 이후 단계를 진행하되, 리포트 최상단에 실패를 명시한다. +반드시 루트에서 실행한다 — `--filter web`으로 좁히면 ui-web 승격 산출물(컴포넌트·스토리)이 검사에서 빠진다. 셋 중 하나라도 실패하면 이후 단계를 진행하되, 리포트 최상단에 실패를 명시한다. ### 2. 규칙 감사 (코드 직접 확인) diff --git a/CLAUDE.md b/CLAUDE.md index 51fe5006..6af29cd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,3 +17,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail | 2026-07-15 | 수술적 변경 원칙 추가 (범위 밖 코드 불간섭, 고아 정리, 기존 죽은 코드는 보고만) | skills/page-implementation | 외부 코딩 가이드에서 하네스에 없던 원칙만 선별 흡수 | | 2026-07-21 | 컨벤션 정본 단일화 — 커밋 prefix 표를 훅 기준으로(design·hotfix→style·setting), 브랜치 타입 feat/fix/hotfix로 통일, PR 템플릿 플레이스홀더 정정, Node 고정(.nvmrc·engines), 리뷰 CI concurrency | README.md, skills/ticket-to-spec, .github, .nvmrc | MSG-163 하네스 감사 — 문서·훅·스킬 3원 불일치로 README 준수 커밋이 훅에 거부되는 함정 제거 | | 2026-07-21 | tsconfig strict 명시(TS6 기본값이나 다운그레이드 대비), index.html lang=ko·서비스명 title | apps/web | MSG-163 — 감사 재검증에서 "non-strict" 지적은 철회됐고(TS6 기본 strict) 명시만 보강, 문서 메타는 a11y 결함 | +| 2026-07-21 | 검증 게이트를 전 패키지로 확장 — ui-web eslint 신설, 루트 lint/typecheck/test 스크립트, 검증 스킬 명령을 루트 기준으로 교체. 미사용 의존성 6종·죽은 lib/ 제거 | 루트·packages 설정, apps/web, skills/page-verification | MSG-163 — ui-web 컴포넌트 21개가 lint 0%·stories 20개가 typecheck 0% 커버였던 사각 해소 | diff --git a/apps/web/package.json b/apps/web/package.json index 6c8d4d6a..9428e7c6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,23 +15,17 @@ "@fillmap/design-tokens": "workspace:*", "@fillmap/ui-web": "workspace:*", "@fontsource-variable/inter": "^5.2.8", - "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.101.2", - "axios": "^1.18.1", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.4.0", "lucide-react": "^1.24.0", "radix-ui": "^1.6.2", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-hook-form": "^7.81.0", "react-kakao-maps-sdk": "^1.2.1", "react-router-dom": "^7.18.1", "shadcn": "^4.13.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", - "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts deleted file mode 100644 index 8a8f8a72..00000000 --- a/apps/web/src/lib/utils.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { cn } from "./utils"; - -describe("cn", () => { - it("조건부 클래스를 병합한다", () => { - const hidden = false as boolean; - expect(cn("flex", hidden && "hidden", "gap-md")).toBe("flex gap-md"); - }); - - it("충돌하는 tailwind 클래스는 뒤의 값이 이긴다", () => { - expect(cn("p-2", "p-4")).toBe("p-4"); - }); - - // 알려진 한계: twMerge 기본 설정은 커스텀 토큰 클래스(p-xs 등)의 충돌을 - // 인식하지 못해 둘 다 유지된다. extendTailwindMerge 설정 전까지의 현재 동작. - // docs/decisions/DECISIONS.md 2026-07-15 항목 참조. - it("커스텀 토큰 클래스 충돌은 병합되지 않는다 (미설정 상태)", () => { - expect(cn("p-xs", "p-xl")).toBe("p-xs p-xl"); - }); -}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts deleted file mode 100644 index bd0c391d..00000000 --- a/apps/web/src/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} diff --git a/package.json b/package.json index fb4a3f2f..637b4157 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "pnpm --filter web dev", "build": "pnpm --filter web build", - "lint": "pnpm --filter web lint", + "lint": "pnpm -r run lint", + "typecheck": "pnpm -r run typecheck", + "test": "pnpm --filter web test run", "storybook": "pnpm --filter @fillmap/ui-web storybook", "build-storybook": "pnpm --filter @fillmap/ui-web build-storybook", "prepare": "husky" diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index a8ac74e7..fdf3d417 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -7,5 +7,11 @@ "types": "./src/index.ts", "exports": { ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "~6.0.2" } } diff --git a/packages/design-tokens/tsconfig.json b/packages/design-tokens/tsconfig.json new file mode 100644 index 00000000..27429a31 --- /dev/null +++ b/packages/design-tokens/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} diff --git a/packages/tailwind-preset/package.json b/packages/tailwind-preset/package.json index cedd7ccd..30813bf1 100644 --- a/packages/tailwind-preset/package.json +++ b/packages/tailwind-preset/package.json @@ -8,10 +8,14 @@ "exports": { ".": "./index.ts" }, + "scripts": { + "typecheck": "tsc --noEmit" + }, "dependencies": { "@fillmap/design-tokens": "workspace:*" }, "devDependencies": { - "tailwindcss": "^4.3.2" + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2" } } diff --git a/packages/tailwind-preset/tsconfig.json b/packages/tailwind-preset/tsconfig.json new file mode 100644 index 00000000..194f2089 --- /dev/null +++ b/packages/tailwind-preset/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["index.ts"] +} diff --git a/packages/ui-web/eslint.config.js b/packages/ui-web/eslint.config.js new file mode 100644 index 00000000..53216b44 --- /dev/null +++ b/packages/ui-web/eslint.config.js @@ -0,0 +1,20 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["storybook-static"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]); diff --git a/packages/ui-web/package.json b/packages/ui-web/package.json index d67fb358..4399bb5e 100644 --- a/packages/ui-web/package.json +++ b/packages/ui-web/package.json @@ -9,6 +9,7 @@ ".": "./src/index.ts" }, "scripts": { + "lint": "eslint .", "typecheck": "tsc --noEmit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" @@ -25,8 +26,13 @@ "react": ">=19" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@fillmap/tailwind-preset": "workspace:*", "@fontsource-variable/inter": "^5.2.8", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.7.0", + "typescript-eslint": "^8.62.0", "@storybook/react-vite": "^10.5.0", "@tailwindcss/vite": "^4.3.2", "@types/react": "^19.2.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index deca2098..a4b28ac8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,24 +23,12 @@ importers: '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 - '@hookform/resolvers': - specifier: ^5.4.0 - version: 5.4.0(react-hook-form@7.81.0(react@19.2.7)) '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@19.2.7) - axios: - specifier: ^1.18.1 - version: 1.18.1 - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 clsx: specifier: ^2.1.1 version: 2.1.1 - date-fns: - specifier: ^4.4.0 - version: 4.4.0 lucide-react: specifier: ^1.24.0 version: 1.24.0(react@19.2.7) @@ -53,9 +41,6 @@ importers: react-dom: specifier: ^19.2.7 version: 19.2.7(react@19.2.7) - react-hook-form: - specifier: ^7.81.0 - version: 7.81.0(react@19.2.7) react-kakao-maps-sdk: specifier: ^1.2.1 version: 1.2.1(kakao.maps.d.ts@0.1.40)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -71,9 +56,6 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 - zod: - specifier: ^4.4.3 - version: 4.4.3 zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) @@ -136,7 +118,11 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) - packages/design-tokens: {} + packages/design-tokens: + devDependencies: + typescript: + specifier: ~6.0.2 + version: 6.0.3 packages/tailwind-preset: dependencies: @@ -147,6 +133,9 @@ importers: tailwindcss: specifier: ^4.3.2 version: 4.3.2 + typescript: + specifier: ~6.0.2 + version: 6.0.3 packages/ui-web: dependencies: @@ -169,6 +158,9 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) '@fillmap/tailwind-preset': specifier: workspace:* version: link:../tailwind-preset @@ -187,6 +179,15 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + eslint: + specifier: ^10.6.0 + version: 10.6.0(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.6.0(jiti@2.7.0)) + globals: + specifier: ^17.7.0 + version: 17.7.0 react: specifier: ^19.2.7 version: 19.2.7 @@ -202,6 +203,9 @@ importers: typescript: specifier: ~6.0.2 version: 6.0.3 + typescript-eslint: + specifier: ^8.62.0 + version: 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.1.4 version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) @@ -658,11 +662,6 @@ packages: peerDependencies: hono: ^4 - '@hookform/resolvers@5.4.0': - resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} - peerDependencies: - react-hook-form: ^7.55.0 - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1757,9 +1756,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@standard-schema/utils@0.3.0': - resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@storybook/builder-vite@10.5.0': resolution: {integrity: sha512-KXlifNIThDgS84KqVAJXyilool8OLTWp6DGoO9h5bHM2IPLe7UcdKfOzMUBkQ807mWBk4aW1yGEekj7kC2dvmg==} peerDependencies: @@ -2148,10 +2144,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -2212,16 +2204,10 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - atomically@1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} - axios@1.18.1: - resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2308,10 +2294,6 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2387,9 +2369,6 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - date-fns@4.4.0: - resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} - debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -2441,10 +2420,6 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2537,10 +2512,6 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -2714,19 +2685,6 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2809,10 +2767,6 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -2835,10 +2789,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -3193,18 +3143,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -3447,10 +3389,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3497,12 +3435,6 @@ packages: peerDependencies: react: ^19.2.7 - react-hook-form@7.81.0: - resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -4583,11 +4515,6 @@ snapshots: dependencies: hono: 4.12.28 - '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.7))': - dependencies: - '@standard-schema/utils': 0.3.0 - react-hook-form: 7.81.0(react@19.2.7) - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5624,8 +5551,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@standard-schema/utils@0.3.0': {} - '@storybook/builder-vite@10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@storybook/csf-plugin': 10.5.0(esbuild@0.28.1)(storybook@10.5.0(@types/react@19.2.17)(react@19.2.7))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) @@ -6047,12 +5972,6 @@ snapshots: acorn@8.17.0: {} - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -6101,20 +6020,8 @@ snapshots: dependencies: tslib: 2.8.1 - asynckit@0.4.0: {} - atomically@1.7.0: {} - axios@1.18.1: - dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.6 - https-proxy-agent: 5.0.1 - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - - supports-color - balanced-match@4.0.4: {} baseline-browser-mapping@2.10.42: {} @@ -6201,10 +6108,6 @@ snapshots: code-block-writer@13.0.3: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@11.1.0: {} commander@14.0.3: {} @@ -6274,8 +6177,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - date-fns@4.4.0: {} - debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -6305,8 +6206,6 @@ snapshots: define-lazy-prop@3.0.0: {} - delayed-stream@1.0.0: {} - depd@2.0.0: {} dequal@2.0.3: {} @@ -6375,13 +6274,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -6644,16 +6536,6 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0: {} - - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - forwarded@0.2.0: {} fresh@2.0.0: {} @@ -6726,10 +6608,6 @@ snapshots: has-symbols@1.1.0: {} - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -6756,13 +6634,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - human-signals@2.1.0: {} human-signals@8.0.1: {} @@ -7027,14 +6898,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -7295,8 +7160,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@2.1.0: {} - punycode@2.3.1: {} qs@6.15.3: @@ -7402,10 +7265,6 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 - react-hook-form@7.81.0(react@19.2.7): - dependencies: - react: 19.2.7 - react-is@17.0.2: {} react-kakao-maps-sdk@1.2.1(kakao.maps.d.ts@0.1.40)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): From 25aea9779c6838313d6c6efd2f4a667e9a0d5aec Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 16:19:53 +0900 Subject: [PATCH 064/281] =?UTF-8?q?MSG-163=20setting:=20CI=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR(develop·main 대상)·develop push 시 install→lint→typecheck→test→build→build-storybook - pnpm 버전은 packageManager 필드 단일 출처, Node는 .nvmrc, setup-node pnpm 캐시 - concurrency로 연속 push 시 이전 실행 취소 - build-storybook 로컬 실행 검증 완료 --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 49 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9e7ced63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + branches: [develop, main] + push: + branches: [develop] + +# 같은 PR/브랜치에 연속 push 시 이전 실행 취소 +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + # pnpm 버전은 루트 package.json의 packageManager 필드가 단일 출처 — version 입력 생략 + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: pnpm + + # CI 환경에서는 pnpm이 자동으로 frozen-lockfile 모드로 동작하지만 명시성을 위해 지정 + - name: Install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Build Storybook + run: pnpm build-storybook diff --git a/CLAUDE.md b/CLAUDE.md index 6af29cd0..0accd1ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,3 +18,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail | 2026-07-21 | 컨벤션 정본 단일화 — 커밋 prefix 표를 훅 기준으로(design·hotfix→style·setting), 브랜치 타입 feat/fix/hotfix로 통일, PR 템플릿 플레이스홀더 정정, Node 고정(.nvmrc·engines), 리뷰 CI concurrency | README.md, skills/ticket-to-spec, .github, .nvmrc | MSG-163 하네스 감사 — 문서·훅·스킬 3원 불일치로 README 준수 커밋이 훅에 거부되는 함정 제거 | | 2026-07-21 | tsconfig strict 명시(TS6 기본값이나 다운그레이드 대비), index.html lang=ko·서비스명 title | apps/web | MSG-163 — 감사 재검증에서 "non-strict" 지적은 철회됐고(TS6 기본 strict) 명시만 보강, 문서 메타는 a11y 결함 | | 2026-07-21 | 검증 게이트를 전 패키지로 확장 — ui-web eslint 신설, 루트 lint/typecheck/test 스크립트, 검증 스킬 명령을 루트 기준으로 교체. 미사용 의존성 6종·죽은 lib/ 제거 | 루트·packages 설정, apps/web, skills/page-verification | MSG-163 — ui-web 컴포넌트 21개가 lint 0%·stories 20개가 typecheck 0% 커버였던 사각 해소 | +| 2026-07-21 | CI 게이트 신설 — PR(develop·main)·develop push 시 lint→typecheck→test→build→build-storybook, concurrency 취소 | .github/workflows/ci.yml | MSG-163 — 기계 게이트 없이 리뷰봇에만 의존하던 회귀 방지 공백 해소, 리뷰봇·사람은 화면 리뷰에 집중 | From 324bb6a9f5c032e3c6fa2144a541642d285b27ec Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 16:21:31 +0900 Subject: [PATCH 065/281] =?UTF-8?q?MSG-163=20docs:=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=201=EC=A1=B0=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=95=EB=B9=84=20=E2=80=94=20px=20=EC=9E=84?= =?UTF-8?q?=EC=9D=98=EA=B0=92=20=EC=A0=84=EB=A9=B4=20=EA=B8=88=EC=A7=80?= =?UTF-8?q?=C2=B7=EC=8A=A4=EC=BC=80=EC=9D=BC=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EA=B7=9C=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 틀린 서술 정정: Tailwind v4는 0.25 배수 전부 동적 생성 — '스케일에 없는 값(6px, 14px)' 예시는 사실과 다름(1.5·3.5로 표현됨) - 'variant 정의 안에서만' 예외를 폐지하고 px 임의값 전면 금지 + eslint 기계 강제로 단순화 (선례 인용 침식 차단) - 시맨틱 토큰(px 고정) vs 숫자 스케일(rem, 폰트 스케일 추종)의 단위 의미 차이 명기 - README의 '길이 단위는 rem 기준' 문구를 실제 정책으로 구체화 --- README.md | 2 +- docs/DESIGN_SYSTEM.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ddfddd34..eb8a204c 100644 --- a/README.md +++ b/README.md @@ -108,4 +108,4 @@ MSG-120 docs: README에 브랜치 전략 설명 추가 ### 스타일 - Tailwind + 디자인 토큰 사용 — 임의 색상·수치 하드코딩 대신 토큰 사용 ([docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md) 준수) -- 길이 단위는 rem 기준 +- 컴포넌트 고유 치수는 Tailwind 숫자 스케일 클래스(rem 기반, 사용자 폰트 설정 추종) 사용 — px 임의값(`w-[40px]`)은 eslint가 금지. 시맨틱 토큰(`p-md` 등)은 px 고정 diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index e931d0cc..c2ac508f 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -23,7 +23,8 @@ apps/web (tailwind.config.ts) → @fillmap/tailwind-preset → @fillmap/desi ## 반드시 지킬 규칙 (6개조) -1. **색상·크기·타이포 값의 유일한 출처는 `design-tokens`.** 컴포넌트/앱 코드에 hex, px 리터럴 금지. Tailwind 임의값(`bg-[#fff]`) 금지 — 단, 컴포넌트 고유 치수(`min-w-[60px]` 등)는 variant 정의 안에서만 허용. 이 예외 안에서도 Tailwind 기본 스케일(4px 단위)로 정확히 표현되는 값은 임의값 대신 스케일 클래스를 쓴다 — 예: `min-w-[40px]` 대신 `min-w-10`, `p-[16px]` 대신 `p-4`. 스케일에 없는 값(예: 6px, 14px)에서만 임의값이 남는다. +1. **색상·크기·타이포 값의 유일한 출처는 `design-tokens`.** 컴포넌트/앱 코드에 hex 리터럴과 색상 임의값(`bg-[#fff]`) 금지. 간격·치수는 시맨틱 토큰(`p-md`, `gap-xs`)을 우선하고, 토큰에 없는 컴포넌트 고유 치수는 **Tailwind 숫자 스케일 클래스**로 쓴다 — `w-[40px]`이 아니라 `w-10`. Tailwind v4는 0.25 단위 배수를 전부 동적 생성하므로 정수 px 값은 모두 스케일로 표현된다(6px=`1.5`, 7px=`1.75`, 10px=`2.5`, 14px=`3.5`, 1px=`px`). **px 임의값(`w-[40px]` 등)은 전면 금지**이며 eslint(better-tailwindcss)가 기계 강제한다. 임의값이 남는 곳은 스케일로 표현 불가한 값(vh·%·`calc()` 등)뿐이고, 스케일 ± 보정이 필요하면 `py-[calc(--spacing(4)-1px)]`처럼 `--spacing()` 함수를 쓴다. + - **단위 의미 주의**: 시맨틱 토큰(`p-md`=16px)은 px 고정, 숫자 스케일(`p-4`=1rem)은 rem 기반이라 사용자 브라우저 폰트 설정을 따라 확대된다. 후자가 접근성상 의도된 동작이다 (2026-07-21 MSG-163에서 px 임의값 78건을 스케일로 전환하며 확정). 2. **원시 토큰(`blue-500` 등)보다 시맨틱 토큰(`primary`, `background` 등) 우선 사용.** 시맨틱으로 표현 안 되는 경우에만 원시 토큰 직접 사용. 3. **`ui-web`(추후 `ui-native`)에는 도메인 무관 컴포넌트만.** API 호출·비즈니스 로직은 각 앱의 `features/`로. 4. **variant API는 `design-tokens/src/variants.ts`의 공용 타입에서 시작.** 웹/앱 컴포넌트가 같은 union 타입을 import한다. 한쪽에만 variant를 추가하지 않는다. From f7415045b578babe7cc8ff31a39cc836252e61c6 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Tue, 21 Jul 2026 16:25:35 +0900 Subject: [PATCH 066/281] =?UTF-8?q?MSG-163=20style:=20=EC=A0=95=EC=88=98?= =?UTF-8?q?=20px=20=EC=9E=84=EC=9D=98=EA=B0=92=20119=EA=B1=B4=EC=9D=84=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=9D=BC=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EC=B9=98=ED=99=98=20+=20better-tailwindcss=20?= =?UTF-8?q?=EB=A6=B0=ED=8A=B8=20=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eslint-plugin-better-tailwindcss no-restricted-classes 도입 (web·ui-web 동일 패턴, 한국어 안내) · 스페이싱 계열 정수 px 임의값 금지, 색상 임의값([#…, rgb(], hsl(], oklch(]) 금지 - 42개 파일 119건 치환: w-[40px]→w-10, gap-[6px]→gap-1.5, py-[7px]→py-1.75, py-[1px]→py-px 등 · vh·%·calc()·소수 px(border-[1.5px])·radius(rounded-[20px])는 규칙상 정당한 임의값으로 유지 · px 고정→rem 추종(사용자 폰트 스케일 대응) 동작 변경 — DESIGN_SYSTEM 1조에 명기, 사용자 승인 - 검증: lint 0건, vitest 151/151, typecheck 4패키지, build 통과, dist CSS에 신규 클래스 생성 실증(.w-19\.5 등), storybook build 통과 --- CLAUDE.md | 1 + apps/web/eslint.config.js | 25 +++ apps/web/package.json | 1 + .../src/features/upload/ui/BlurOverlay.tsx | 2 +- .../src/features/upload/ui/UploadDropzone.tsx | 4 +- .../src/features/upload/ui/UploadModal.tsx | 4 +- .../src/pages/explore/ui/CellDetailSheet.tsx | 2 +- .../web/src/pages/explore/ui/CellMoreMenu.tsx | 2 +- .../web/src/pages/explore/ui/ReportDialog.tsx | 4 +- .../pages/explore/ui/ReportReasonSelect.tsx | 2 +- .../pages/map-home/ui/CellSummaryPanel.tsx | 8 +- .../web/src/pages/map-home/ui/MapControls.tsx | 2 +- packages/ui-web/eslint.config.js | 27 +++ packages/ui-web/package.json | 9 +- packages/ui-web/src/app-header.stories.tsx | 2 +- packages/ui-web/src/app-header.tsx | 10 +- packages/ui-web/src/avatar.tsx | 6 +- packages/ui-web/src/bottom-nav.stories.tsx | 10 +- packages/ui-web/src/bottom-nav.tsx | 12 +- packages/ui-web/src/bottom-sheet.stories.tsx | 4 +- packages/ui-web/src/bottom-sheet.tsx | 4 +- packages/ui-web/src/button.stories.tsx | 2 +- packages/ui-web/src/button.tsx | 4 +- packages/ui-web/src/cell-badge.tsx | 2 +- packages/ui-web/src/chip.tsx | 4 +- packages/ui-web/src/dots.tsx | 4 +- packages/ui-web/src/fab.tsx | 4 +- packages/ui-web/src/grid-cell.stories.tsx | 8 +- packages/ui-web/src/grid-cell.tsx | 2 +- packages/ui-web/src/input.stories.tsx | 2 +- packages/ui-web/src/input.tsx | 2 +- packages/ui-web/src/map-icon-button.tsx | 6 +- packages/ui-web/src/modal-card.stories.tsx | 6 +- packages/ui-web/src/modal-card.tsx | 10 +- packages/ui-web/src/search-bar.stories.tsx | 4 +- packages/ui-web/src/search-bar.tsx | 6 +- packages/ui-web/src/selector.stories.tsx | 2 +- packages/ui-web/src/selector.tsx | 4 +- packages/ui-web/src/side-rail.stories.tsx | 14 +- packages/ui-web/src/side-rail.tsx | 8 +- packages/ui-web/src/switch.stories.tsx | 2 +- packages/ui-web/src/switch.tsx | 4 +- packages/ui-web/src/toast.stories.tsx | 4 +- packages/ui-web/src/toast.tsx | 6 +- packages/ui-web/src/video-row.stories.tsx | 4 +- packages/ui-web/src/video-row.tsx | 6 +- packages/ui-web/src/zoom-control.tsx | 12 +- pnpm-lock.yaml | 160 ++++++++++++++++++ 48 files changed, 324 insertions(+), 109 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0accd1ce..946bf231 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,3 +19,4 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + packages(design-tokens · tail | 2026-07-21 | tsconfig strict 명시(TS6 기본값이나 다운그레이드 대비), index.html lang=ko·서비스명 title | apps/web | MSG-163 — 감사 재검증에서 "non-strict" 지적은 철회됐고(TS6 기본 strict) 명시만 보강, 문서 메타는 a11y 결함 | | 2026-07-21 | 검증 게이트를 전 패키지로 확장 — ui-web eslint 신설, 루트 lint/typecheck/test 스크립트, 검증 스킬 명령을 루트 기준으로 교체. 미사용 의존성 6종·죽은 lib/ 제거 | 루트·packages 설정, apps/web, skills/page-verification | MSG-163 — ui-web 컴포넌트 21개가 lint 0%·stories 20개가 typecheck 0% 커버였던 사각 해소 | | 2026-07-21 | CI 게이트 신설 — PR(develop·main)·develop push 시 lint→typecheck→test→build→build-storybook, concurrency 취소 | .github/workflows/ci.yml | MSG-163 — 기계 게이트 없이 리뷰봇에만 의존하던 회귀 방지 공백 해소, 리뷰봇·사람은 화면 리뷰에 집중 | +| 2026-07-21 | 토큰 규칙 기계 강제 — 디자인 시스템 1조 재작성(px 임의값 전면 금지, 틀린 4px·예시 서술 정정), better-tailwindcss 린트 도입(한국어 메시지), 기존 정수 px 임의값 119건을 스케일 클래스로 일괄 치환 | docs/DESIGN_SYSTEM.md, README, 양쪽 eslint.config.js, apps/web·ui-web 소스 42파일 | MSG-163 — 검증 리포트가 기존 위반을 "선례"로 인용해 새 위반을 허용하던 침식 고리 차단. 치환은 px 고정→rem 추종(폰트 스케일 대응) 동작 변경 포함 | diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index ef614d25..4c6d93df 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -3,6 +3,7 @@ import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' +import betterTailwindcss from 'eslint-plugin-better-tailwindcss' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ @@ -15,8 +16,32 @@ export default defineConfig([ reactHooks.configs.flat.recommended, reactRefresh.configs.vite, ], + plugins: { + 'better-tailwindcss': betterTailwindcss, + }, languageOptions: { globals: globals.browser, }, + rules: { + // 디자인 시스템 1조(docs/DESIGN_SYSTEM.md) 기계 강제 — packages/ui-web과 동일 패턴 유지 + 'better-tailwindcss/no-restricted-classes': [ + 'error', + { + restrict: [ + { + pattern: + '^(?:.*:)?-?(?:p[xytblrse]?|m[xytblrse]?|w|h|size|gap(?:-[xy])?|min-w|max-w|min-h|max-h|space-[xy]|translate(?:-[xy])?|top|bottom|left|right|inset(?:-[xy])?|indent)-\\[\\d+px\\]$', + message: + 'px 임의값 금지 — 정수 px는 스케일 클래스로 전부 표현됩니다 (예: w-[40px]→w-10, 6px→1.5, 1px→px). docs/DESIGN_SYSTEM.md 1조', + }, + { + pattern: '\\[(?:#|rgba?\\(|hsla?\\(|oklch\\()', + message: + '색상 임의값 금지 — design-tokens의 시맨틱/원시 토큰 클래스를 사용하세요. docs/DESIGN_SYSTEM.md 1조', + }, + ], + }, + ], + }, }, ]) diff --git a/apps/web/package.json b/apps/web/package.json index 9428e7c6..b8942034 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "eslint": "^10.6.0", + "eslint-plugin-better-tailwindcss": "^4.7.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", diff --git a/apps/web/src/features/upload/ui/BlurOverlay.tsx b/apps/web/src/features/upload/ui/BlurOverlay.tsx index 9c87dcf4..5f76eb2b 100644 --- a/apps/web/src/features/upload/ui/BlurOverlay.tsx +++ b/apps/web/src/features/upload/ui/BlurOverlay.tsx @@ -24,7 +24,7 @@ export const BlurOverlay = ({ regions }: BlurOverlayProps) => ( height: `${region.box.h}%`, }} > - + {region.label}
diff --git a/apps/web/src/features/upload/ui/UploadDropzone.tsx b/apps/web/src/features/upload/ui/UploadDropzone.tsx index fc7e991d..346f7784 100644 --- a/apps/web/src/features/upload/ui/UploadDropzone.tsx +++ b/apps/web/src/features/upload/ui/UploadDropzone.tsx @@ -70,11 +70,11 @@ export const UploadDropzone = ({ onDragLeave={() => setDragActive(false)} onDrop={handleDrop} className={cn( - "flex h-[200px] w-full flex-col items-center justify-center gap-[10px] rounded-lg border-[1.5px] border-dashed bg-surface-soft px-md text-center transition-colors", + "flex h-50 w-full flex-col items-center justify-center gap-2.5 rounded-lg border-[1.5px] border-dashed bg-surface-soft px-md text-center transition-colors", dragActive ? "border-primary bg-primary/5" : "border-primary/50", )} > - + diff --git a/apps/web/src/features/upload/ui/UploadModal.tsx b/apps/web/src/features/upload/ui/UploadModal.tsx index e47c1995..d59cbd2d 100644 --- a/apps/web/src/features/upload/ui/UploadModal.tsx +++ b/apps/web/src/features/upload/ui/UploadModal.tsx @@ -133,7 +133,7 @@ export const UploadModal = () => { 영상 업로드 {step === "highlight" && duration !== null ? ( @@ -201,7 +201,7 @@ export const UploadModal = () => { 위치 태그 - + {LOCATION_LABEL} diff --git a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx index fc2d81cf..f53cd7a8 100644 --- a/apps/web/src/pages/explore/ui/CellDetailSheet.tsx +++ b/apps/web/src/pages/explore/ui/CellDetailSheet.tsx @@ -133,7 +133,7 @@ export const CellDetailSheet = ({ cell, className }: CellDetailSheetProps) => { }; const Stat = ({ value, label }: { value: string; label: string }) => ( -
+
{label}
{value}
diff --git a/apps/web/src/pages/explore/ui/CellMoreMenu.tsx b/apps/web/src/pages/explore/ui/CellMoreMenu.tsx index 362a9580..cc9603e9 100644 --- a/apps/web/src/pages/explore/ui/CellMoreMenu.tsx +++ b/apps/web/src/pages/explore/ui/CellMoreMenu.tsx @@ -20,7 +20,7 @@ export const CellMoreMenu = ({ children, onReport }: CellMoreMenuProps) => ( 수정하기 diff --git a/apps/web/src/pages/explore/ui/ReportDialog.tsx b/apps/web/src/pages/explore/ui/ReportDialog.tsx index d247f875..f027037d 100644 --- a/apps/web/src/pages/explore/ui/ReportDialog.tsx +++ b/apps/web/src/pages/explore/ui/ReportDialog.tsx @@ -60,7 +60,7 @@ export const ReportDialog = ({ open, onOpenChange }: ReportDialogProps) => { 영상 신고 { {toastVisible && ( -
+
)} diff --git a/apps/web/src/pages/explore/ui/ReportReasonSelect.tsx b/apps/web/src/pages/explore/ui/ReportReasonSelect.tsx index 10821be5..8ebd79c5 100644 --- a/apps/web/src/pages/explore/ui/ReportReasonSelect.tsx +++ b/apps/web/src/pages/explore/ui/ReportReasonSelect.tsx @@ -20,7 +20,7 @@ export const ReportReasonSelect = ({ diff --git a/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx b/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx index 2683c4b6..c51fb33c 100644 --- a/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx +++ b/apps/web/src/pages/map-home/ui/CellSummaryPanel.tsx @@ -27,11 +27,11 @@ const CellCard = ({ ) : ( - + )}

{title}

- + {right} diff --git a/packages/ui-web/src/avatar.tsx b/packages/ui-web/src/avatar.tsx index 9b763e8f..ee793b27 100644 --- a/packages/ui-web/src/avatar.tsx +++ b/packages/ui-web/src/avatar.tsx @@ -9,9 +9,9 @@ const avatarVariants = cva( { variants: { size: { - lg: "size-[48px]", - md: "size-[36px]", - sm: "size-[28px]", + lg: "size-12", + md: "size-9", + sm: "size-7", }, }, defaultVariants: { size: "lg" }, diff --git a/packages/ui-web/src/bottom-nav.stories.tsx b/packages/ui-web/src/bottom-nav.stories.tsx index 9bdcfe00..87628bf6 100644 --- a/packages/ui-web/src/bottom-nav.stories.tsx +++ b/packages/ui-web/src/bottom-nav.stories.tsx @@ -3,10 +3,10 @@ import { Compass, Home, LayoutGrid, User } from "lucide-react"; import { BottomNav } from "./bottom-nav"; const items = [ - { key: "home", label: "홈", icon: }, - { key: "explore", label: "탐색", icon: }, - { key: "dex", label: "도감", icon: }, - { key: "profile", label: "프로필", icon: }, + { key: "home", label: "홈", icon: }, + { key: "explore", label: "탐색", icon: }, + { key: "dex", label: "도감", icon: }, + { key: "profile", label: "프로필", icon: }, ]; const meta = { @@ -23,7 +23,7 @@ type Story = StoryObj; export const Playground: Story = { render: (args) => ( -
+
), diff --git a/packages/ui-web/src/bottom-nav.tsx b/packages/ui-web/src/bottom-nav.tsx index a56e4344..4cfc70ab 100644 --- a/packages/ui-web/src/bottom-nav.tsx +++ b/packages/ui-web/src/bottom-nav.tsx @@ -50,7 +50,7 @@ export const BottomNav = ({ isActive ? "text-primary" : "text-foreground-muted", )} > - + {item.icon} -
+ ); diff --git a/packages/ui-web/src/bottom-sheet.stories.tsx b/packages/ui-web/src/bottom-sheet.stories.tsx index eabd24bc..a1ea5a4c 100644 --- a/packages/ui-web/src/bottom-sheet.stories.tsx +++ b/packages/ui-web/src/bottom-sheet.stories.tsx @@ -16,7 +16,7 @@ type Story = StoryObj; export const Playground: Story = { render: (args) => ( -
+
@@ -29,7 +29,7 @@ export const Playground: Story = { export const Docked: Story = { args: { handle: false }, render: (args) => ( -
+
diff --git a/packages/ui-web/src/bottom-sheet.tsx b/packages/ui-web/src/bottom-sheet.tsx index 7685ccf1..2c677e19 100644 --- a/packages/ui-web/src/bottom-sheet.tsx +++ b/packages/ui-web/src/bottom-sheet.tsx @@ -33,13 +33,13 @@ export const BottomSheet = ({
{handle && (
- +
)} {(title || actionLabel) && ( diff --git a/packages/ui-web/src/button.stories.tsx b/packages/ui-web/src/button.stories.tsx index be4df201..c52629fb 100644 --- a/packages/ui-web/src/button.stories.tsx +++ b/packages/ui-web/src/button.stories.tsx @@ -32,7 +32,7 @@ export const AllVariants: Story = {
{variants.map((variant) => (
- + {variant} {sizes.map((size) => ( diff --git a/packages/ui-web/src/button.tsx b/packages/ui-web/src/button.tsx index a10f070e..3b3776f5 100644 --- a/packages/ui-web/src/button.tsx +++ b/packages/ui-web/src/button.tsx @@ -18,8 +18,8 @@ const buttonVariants = cva( danger: "bg-error text-primary-foreground", }, size: { - lg: "h-[48px] min-w-[140px] rounded-md px-lg text-fm-title leading-none", - sm: "h-[36px] min-w-[104px] rounded-sm px-md text-fm-body-strong", + lg: "h-12 min-w-35 rounded-md px-lg text-fm-title leading-none", + sm: "h-9 min-w-26 rounded-sm px-md text-fm-body-strong", }, }, defaultVariants: { variant: "primary", size: "lg" }, diff --git a/packages/ui-web/src/cell-badge.tsx b/packages/ui-web/src/cell-badge.tsx index e478125e..dec7a55f 100644 --- a/packages/ui-web/src/cell-badge.tsx +++ b/packages/ui-web/src/cell-badge.tsx @@ -15,7 +15,7 @@ interface CellBadgeProps { export const CellBadge = ({ label, className }: CellBadgeProps) => ( diff --git a/packages/ui-web/src/chip.tsx b/packages/ui-web/src/chip.tsx index 463f1e33..212d776b 100644 --- a/packages/ui-web/src/chip.tsx +++ b/packages/ui-web/src/chip.tsx @@ -27,7 +27,7 @@ export const Chip = ({ type={type} aria-pressed={active} className={cn( - "inline-flex h-[32px] items-center gap-xxs rounded-full px-[14px] text-fm-label transition-[filter,background-color] active:brightness-[0.86] disabled:pointer-events-none disabled:opacity-50", + "inline-flex h-8 items-center gap-xxs rounded-full px-3.5 text-fm-label transition-[filter,background-color] active:brightness-[0.86] disabled:pointer-events-none disabled:opacity-50", active ? "bg-primary text-primary-foreground" : "bg-background text-foreground", @@ -35,7 +35,7 @@ export const Chip = ({ )} {...props} > - {active && } + {active && } {text} ); diff --git a/packages/ui-web/src/dots.tsx b/packages/ui-web/src/dots.tsx index b0a9cb38..a6b0981c 100644 --- a/packages/ui-web/src/dots.tsx +++ b/packages/ui-web/src/dots.tsx @@ -15,12 +15,12 @@ interface DotsProps { * */ export const Dots = ({ count = 3, activeIndex = 0, className }: DotsProps) => ( -
+
{Array.from({ length: count }, (_, i) => ( diff --git a/packages/ui-web/src/fab.tsx b/packages/ui-web/src/fab.tsx index d28242fb..355bc02c 100644 --- a/packages/ui-web/src/fab.tsx +++ b/packages/ui-web/src/fab.tsx @@ -17,11 +17,11 @@ export const Fab = ({ icon, className, type = "button", ...props }: FabProps) => ); diff --git a/packages/ui-web/src/grid-cell.stories.tsx b/packages/ui-web/src/grid-cell.stories.tsx index 87b2ad3c..2fc545e7 100644 --- a/packages/ui-web/src/grid-cell.stories.tsx +++ b/packages/ui-web/src/grid-cell.stories.tsx @@ -4,7 +4,7 @@ import { GridCell } from "./grid-cell"; const meta = { title: "Components/GridCell", component: GridCell, - args: { state: "default", className: "size-[130px]" }, + args: { state: "default", className: "size-32.5" }, argTypes: { state: { control: "select", options: ["default", "collected", "selected"] }, }, @@ -19,9 +19,9 @@ export const Playground: Story = {}; export const AllStates: Story = { render: () => (
- - - + + +
), }; diff --git a/packages/ui-web/src/grid-cell.tsx b/packages/ui-web/src/grid-cell.tsx index e744e5bf..3c8737f8 100644 --- a/packages/ui-web/src/grid-cell.tsx +++ b/packages/ui-web/src/grid-cell.tsx @@ -25,7 +25,7 @@ interface GridCellProps /** * @example - * + * */ export const GridCell = ({ state, diff --git a/packages/ui-web/src/input.stories.tsx b/packages/ui-web/src/input.stories.tsx index 94eb0524..e5c2078a 100644 --- a/packages/ui-web/src/input.stories.tsx +++ b/packages/ui-web/src/input.stories.tsx @@ -15,7 +15,7 @@ export const Playground: Story = {}; /** default / focus(클릭해서 확인) / filled / error — Figma State와 1:1 */ export const AllStates: Story = { render: () => ( -
+
diff --git a/packages/ui-web/src/input.tsx b/packages/ui-web/src/input.tsx index d2218549..e8e5ed51 100644 --- a/packages/ui-web/src/input.tsx +++ b/packages/ui-web/src/input.tsx @@ -18,7 +18,7 @@ export const Input = ({ error, className, ...props }: InputProps) => ( {icon === "back" ? ( - + ) : ( - + )} ); diff --git a/packages/ui-web/src/modal-card.stories.tsx b/packages/ui-web/src/modal-card.stories.tsx index 95fbde94..5f743171 100644 --- a/packages/ui-web/src/modal-card.stories.tsx +++ b/packages/ui-web/src/modal-card.stories.tsx @@ -20,7 +20,7 @@ export const Playground: Story = { render: (args) => (
-
+
콘텐츠 영역
@@ -34,7 +34,7 @@ export const ConfirmDisabled: Story = { render: (args) => (
-
+
콘텐츠 영역
@@ -48,7 +48,7 @@ export const ConfirmDanger: Story = { render: (args) => (
-
+
콘텐츠 영역
diff --git a/packages/ui-web/src/modal-card.tsx b/packages/ui-web/src/modal-card.tsx index e2795821..8cf01c81 100644 --- a/packages/ui-web/src/modal-card.tsx +++ b/packages/ui-web/src/modal-card.tsx @@ -44,7 +44,7 @@ export const ModalCard = ({ role="dialog" aria-label={title} className={cn( - "flex w-full max-w-[480px] flex-col gap-md rounded-[20px] bg-surface-elevated p-[28px] shadow-modal", + "flex w-full max-w-120 flex-col gap-md rounded-[20px] bg-surface-elevated p-7 shadow-modal", className, )} > @@ -59,7 +59,7 @@ export const ModalCard = ({ onClick={onClose} className="shrink-0 text-foreground-muted transition-colors hover:text-foreground" > - + )}
@@ -68,12 +68,12 @@ export const ModalCard = ({ )} {children} {(cancelText || confirmText) && ( -
+
{cancelText && ( @@ -84,7 +84,7 @@ export const ModalCard = ({ onClick={onConfirm} disabled={confirmDisabled} className={cn( - "h-[48px] min-w-0 flex-1 rounded-full text-fm-title leading-none text-primary-foreground transition-[filter] active:brightness-[0.86] disabled:pointer-events-none disabled:opacity-50", + "h-12 min-w-0 flex-1 rounded-full text-fm-title leading-none text-primary-foreground transition-[filter] active:brightness-[0.86] disabled:pointer-events-none disabled:opacity-50", confirmVariant === "danger" ? "bg-error" : "bg-primary", )} > diff --git a/packages/ui-web/src/search-bar.stories.tsx b/packages/ui-web/src/search-bar.stories.tsx index 94b7196e..20cb3e29 100644 --- a/packages/ui-web/src/search-bar.stories.tsx +++ b/packages/ui-web/src/search-bar.stories.tsx @@ -13,7 +13,7 @@ type Story = StoryObj; /** focused 상태는 입력창 클릭으로 확인 */ export const Playground: Story = { render: (args) => ( -
+
), @@ -23,7 +23,7 @@ export const Playground: Story = { export const ClickableIcon: Story = { args: { onSearch: () => alert("검색") }, render: (args) => ( -
+
), diff --git a/packages/ui-web/src/search-bar.tsx b/packages/ui-web/src/search-bar.tsx index 67324672..34e9e948 100644 --- a/packages/ui-web/src/search-bar.tsx +++ b/packages/ui-web/src/search-bar.tsx @@ -24,7 +24,7 @@ export const SearchBar = ({ }: SearchBarProps) => (
@@ -40,10 +40,10 @@ export const SearchBar = ({ onClick={onSearch} className="shrink-0 text-icon transition-opacity active:opacity-60" > - + ) : ( - + )}
); diff --git a/packages/ui-web/src/selector.stories.tsx b/packages/ui-web/src/selector.stories.tsx index a96ee9e2..0c3674f3 100644 --- a/packages/ui-web/src/selector.stories.tsx +++ b/packages/ui-web/src/selector.stories.tsx @@ -18,7 +18,7 @@ export const Playground: Story = {}; /** checkbox/radio × off/on — Figma variant와 1:1 */ export const AllVariants: Story = { render: () => ( -
+
diff --git a/packages/ui-web/src/selector.tsx b/packages/ui-web/src/selector.tsx index 5f6cdf77..4f3a4fd1 100644 --- a/packages/ui-web/src/selector.tsx +++ b/packages/ui-web/src/selector.tsx @@ -10,7 +10,7 @@ import { cn } from "./lib/utils"; * 라디오의 그룹 배타 선택은 사용하는 쪽에서 조합한다. */ const selectorVariants = cva( - "inline-flex size-[20px] shrink-0 items-center justify-center border-[1.5px] border-border bg-surface transition-colors disabled:pointer-events-none disabled:opacity-50", + "inline-flex size-5 shrink-0 items-center justify-center border-[1.5px] border-border bg-surface transition-colors disabled:pointer-events-none disabled:opacity-50", { variants: { type: { @@ -55,7 +55,7 @@ export const Selector = ({ > {type === "checkbox" && ( - + )} diff --git a/packages/ui-web/src/side-rail.stories.tsx b/packages/ui-web/src/side-rail.stories.tsx index e34cdcd4..4440bfbe 100644 --- a/packages/ui-web/src/side-rail.stories.tsx +++ b/packages/ui-web/src/side-rail.stories.tsx @@ -3,11 +3,11 @@ import { Compass, Home, LayoutGrid, MapPin, Upload, User } from "lucide-react"; import { SideRail } from "./side-rail"; const items = [ - { key: "home", label: "홈", icon: }, - { key: "explore", label: "탐색", icon: }, - { key: "upload", label: "업로드", icon: }, - { key: "dex", label: "도감", icon: }, - { key: "profile", label: "프로필", icon: }, + { key: "home", label: "홈", icon: }, + { key: "explore", label: "탐색", icon: }, + { key: "upload", label: "업로드", icon: }, + { key: "dex", label: "도감", icon: }, + { key: "profile", label: "프로필", icon: }, ]; const meta = { @@ -18,7 +18,7 @@ const meta = { activeKey: "home", logo: ( - + ), }, @@ -32,7 +32,7 @@ type Story = StoryObj; export const Playground: Story = { render: (args) => ( -
+
), diff --git a/packages/ui-web/src/side-rail.tsx b/packages/ui-web/src/side-rail.tsx index 504f47ed..7415d127 100644 --- a/packages/ui-web/src/side-rail.tsx +++ b/packages/ui-web/src/side-rail.tsx @@ -37,12 +37,12 @@ export const SideRail = ({ }: SideRailProps) => (
diff --git a/apps/web/src/pages/map-home/ui/VideoMiniPanel.tsx b/apps/web/src/pages/map-home/ui/VideoMiniPanel.tsx new file mode 100644 index 00000000..c3a52b2f --- /dev/null +++ b/apps/web/src/pages/map-home/ui/VideoMiniPanel.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from "react"; +import { Film, X } from "lucide-react"; +import type { VideoMiniSelection } from "@/features/map-home/model/video-mini-panel-store"; +import { + formatMonthDay, + formatRelativeTime, + formatViewCountKo, +} from "@/shared/format"; + +interface VideoMiniPanelProps { + /** 미니 패널 선택 — 영상 데이터 + 내 영상 여부 (video-mini-panel-store) */ + selected: VideoMiniSelection; + /** 닫기 버튼 배선 — Escape 우선 닫기는 페이지 레벨 래핑이 담당 (3차 AC 13) */ + onClose: () => void; +} + +/** + * 영상 미니 디테일 패널 (MSG-277 3차 AC 5·6·8~11) — 네이버 지도 PC 보조 패널 방식. + * 좌측 패널(w-97) 오른쪽에 flush로 붙는 전고 보조 패널 — 배경 지도 조작은 차단하지 않는다 + * (백드롭 없음). 구성: 닫기 버튼 → 재생 영역(HTML5 video controls, videoSrc 없으면 Film + * 플레이스홀더) → 메타(제목·소유 문구·시간·조회수 — FeedVideoCard와 동일 mine 분기, 추정 5). + * 열릴 때(마운트) 포커스를 닫기 버튼으로 옮기고, 카드 교체(리렌더)에는 옮기지 않는다 (AC 6). + * 접힘 시에는 셸의 display:none 래퍼로 좌측 패널과 함께 숨는다 (추정 9). + */ +export const VideoMiniPanel = ({ selected, onClose }: VideoMiniPanelProps) => { + const { video, mine } = selected; + const closeButtonRef = useRef(null); + const videoRef = useRef(null); + + // 열림 = 마운트 1회 — 닫기 버튼 포커스 (AC 6). 교체는 같은 마운트의 리렌더라 다시 옮기지 않는다 + useEffect(() => { + closeButtonRef.current?.focus(); + }, []); + + // 열림·교체 시 자동재생 시도 (추정 4) — 카드 클릭 제스처 직후라 대체로 허용되고, + // autoplay 정책·소스 로드 실패 시엔 무시하고 controls 수동 재생으로 폴백 (VideoPreview 관례) + useEffect(() => { + videoRef.current?.play()?.catch(() => {}); + }, [video.id]); + + return ( + + ); +}; diff --git a/apps/web/src/pages/map-home/ui/feed-video-card.smoke.test.tsx b/apps/web/src/pages/map-home/ui/feed-video-card.smoke.test.tsx new file mode 100644 index 00000000..6a983694 --- /dev/null +++ b/apps/web/src/pages/map-home/ui/feed-video-card.smoke.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CellVideo } from "@/entities/cell"; +import { FeedVideoCard } from "./FeedVideoCard"; + +/** + * 피드 영상 카드 스모크 (MSG-277 3차 AC 4) — 카드 button화 계약. + * 1·2차의 "카드 클릭 no-op(div)"을 대체 — 접근성 이름에 영상 제목이 포함된 button으로 + * 렌더되고, 클릭 시 onSelect가 호출되는 것을 고정한다. 메타 문구 분기는 기존 + * 패널 렌더가 커버하므로 여기서는 버튼 계약만 단정한다. + */ + +/** 고정 픽스처 — 서면 목 관례 (부산 서면 MVP) */ +const VIDEO: CellVideo = { + id: "A-14-v1", + title: "거리 야경 감성 스팟", + viewCount: 12000, + uploadedAt: "2026-07-29T12:00:00.000Z", + durationSec: 60, + uploaderHandle: "@busan.vlog", + videoSrc: "https://mdn.github.io/shared-assets/videos/flower.mp4", +}; + +describe("피드 영상 카드 button화 (3차 AC 4)", () => { + afterEach(() => { + cleanup(); + }); + + it("접근성 이름에 영상 제목이 포함된 button으로 렌더된다 — no-op div 대체 (AC 4)", () => { + render( {}} />); + + expect( + screen.getByRole("button", { name: /거리 야경 감성 스팟/ }), + ).toBeTruthy(); + }); + + it("클릭 시 onSelect가 호출된다 (AC 4)", () => { + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /거리 야경 감성 스팟/ })); + + expect(onSelect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/pages/map-home/ui/home-cell-detail-panel.smoke.test.tsx b/apps/web/src/pages/map-home/ui/home-cell-detail-panel.smoke.test.tsx index f0a3c045..17808273 100644 --- a/apps/web/src/pages/map-home/ui/home-cell-detail-panel.smoke.test.tsx +++ b/apps/web/src/pages/map-home/ui/home-cell-detail-panel.smoke.test.tsx @@ -33,6 +33,9 @@ const DETAIL: HomeCellDetail = { /** onViewAll 필수 prop 신설(MSG-253 AC 11)에 따른 렌더 인자 — Escape 단정과 무관한 no-op */ const noopViewAll = () => {}; +/** onVideoSelect 필수 prop 신설(MSG-277 3차 AC 4·9)에 따른 렌더 인자 — Escape 단정과 무관한 no-op */ +const noopVideoSelect = () => {}; + describe("홈 셀 상세 패널 Escape 배선", () => { afterEach(() => { cleanup(); @@ -45,6 +48,7 @@ describe("홈 셀 상세 패널 Escape 배선", () => { @@ -61,6 +65,7 @@ describe("홈 셀 상세 패널 Escape 배선", () => { render( , diff --git a/apps/web/src/pages/map-home/ui/theme-feed-panel.smoke.test.tsx b/apps/web/src/pages/map-home/ui/theme-feed-panel.smoke.test.tsx index 8c3763be..2845a3fc 100644 --- a/apps/web/src/pages/map-home/ui/theme-feed-panel.smoke.test.tsx +++ b/apps/web/src/pages/map-home/ui/theme-feed-panel.smoke.test.tsx @@ -5,8 +5,10 @@ import { ThemeFeedPanel } from "./ThemeFeedPanel"; /** * 테마 피드 패널 스모크 (MSG-277 AC 2·7, 확정 4, 추정 6). - * 헤더 개수·셀 섹션 헤더·버튼 부재·Escape 배선(입력 타깃 무시 계약 포함)을 고정한다. + * 헤더 개수·셀 섹션 헤더·CTA 부재·Escape 배선(입력 타깃 무시 계약 포함)을 고정한다. * 피드 파생 규칙 자체는 theme-feed.test.ts가 커버 — 여기서는 렌더 배선만 단정한다. + * MSG-277 3차: 카드 button화(3차 AC 4)로 "버튼 전무" 단정을 "카드 재생 버튼 외 버튼 없음"으로 + * 재작성 — 원 의도(순수 탐색 피드: 하단 CTA·"전체 보기" 없음)는 보존 (스펙 승인 예외). */ /** 고정 픽스처 — 서면 목 관례 (부산 서면 MVP). totalCount(3)는 나열 영상 수 합과 일치 (AC 2) */ @@ -61,24 +63,35 @@ describe("테마 피드 패널 스모크", () => { }); it("헤더에 테마 배지와 실제 나열 영상 총수가 보인다 (AC 2)", () => { - render( {}} />); + render( + {}} onClose={() => {}} />, + ); expect(screen.getByText("핫구역")).toBeTruthy(); expect(screen.getByText("· 3개")).toBeTruthy(); }); it("셀 라벨 섹션 헤더가 보인다 — 피드 내 셀 식별 (AC 7)", () => { - render( {}} />); + render( + {}} onClose={() => {}} />, + ); // 접근성 이름에 섹션 개수("· N개")가 합쳐진다 — 리뷰 반영(헤더 위계 승격)으로 부분 일치 단정 expect(screen.getByRole("heading", { name: /서면 A-14/ })).toBeTruthy(); expect(screen.getByRole("heading", { name: /전포 A-15/ })).toBeTruthy(); }); - it("하단 버튼·'전체 보기'가 없다 — 순수 탐색 피드 (확정 4)", () => { - render( {}} />); + it("버튼은 카드 재생 버튼뿐이고 '전체 보기'가 없다 — 순수 탐색 피드 (확정 4, 3차 AC 4 button화 반영 재작성)", () => { + render( + {}} onClose={() => {}} />, + ); - expect(screen.queryByRole("button")).toBeNull(); + // 나열 영상 3개 = 재생 버튼 3개 — 그 외 버튼(하단 CTA 등)이 없음을 총수로 고정 + const buttons = screen.getAllByRole("button"); + expect(buttons).toHaveLength(3); + for (const button of buttons) { + expect(button.getAttribute("aria-label")).toMatch(/재생$/); + } expect(screen.queryByText("전체 보기")).toBeNull(); }); @@ -87,7 +100,7 @@ describe("테마 피드 패널 스모크", () => { render( <> - + {}} onClose={onClose} /> , ); diff --git a/apps/web/src/pages/map-home/ui/video-mini-panel.smoke.test.tsx b/apps/web/src/pages/map-home/ui/video-mini-panel.smoke.test.tsx new file mode 100644 index 00000000..b6cddf87 --- /dev/null +++ b/apps/web/src/pages/map-home/ui/video-mini-panel.smoke.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { VideoMiniSelection } from "@/features/map-home/model/video-mini-panel-store"; +import { VideoMiniPanel } from "./VideoMiniPanel"; + +/** + * 영상 미니 디테일 패널 스모크 (MSG-277 3차 AC 5·6). + * video 요소(선택 영상 videoSrc)·메타(제목·소유 문구·시간·조회수)·닫기 버튼 렌더와 + * 열림 시 닫기 버튼 포커스(교체 시 이동 없음)를 고정한다. + * 열림/교체 전환 자체는 스토어 테스트(video-mini-panel-store)가 커버 — 여기서는 렌더 계약만. + */ + +// jsdom은 HTMLMediaElement.play를 구현하지 않아 autoplay 시도(추정 4)가 콘솔 노이즈를 남긴다 — +// 계약 대상이 아니므로 무해한 스텁으로 대체한다 +beforeAll(() => { + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); +}); + +/** 고정 픽스처 — 서면 목 관례 (부산 서면 MVP). 내 영상: M월 D일 분기 검증용 과거 고정 시각 */ +const MINE_SELECTION: VideoMiniSelection = { + video: { + id: "A-14-v1", + title: "거리 야경 감성 스팟", + viewCount: 12000, + uploadedAt: "2026-07-15T12:00:00.000Z", + durationSec: 60, + videoSrc: "https://mdn.github.io/shared-assets/videos/flower.mp4", + }, + mine: true, +}; + +/** 타인 영상 — @핸들 분기 + 다른 videoSrc(교체 검증용) */ +const OTHER_SELECTION: VideoMiniSelection = { + video: { + id: "A-15-v1", + title: "숨은 골목 카페 투어", + viewCount: 8410, + uploadedAt: "2026-07-29T12:00:00.000Z", + durationSec: 96, + uploaderHandle: "@jeonpo_alley", + videoSrc: "https://mdn.github.io/shared-assets/videos/friday.mp4", + }, + mine: false, +}; + +describe("영상 미니 디테일 패널 스모크 (3차 AC 5·6)", () => { + afterEach(() => { + cleanup(); + }); + + it("선택 영상 videoSrc의 video 요소와 제목·조회수 메타가 렌더된다 (AC 5)", () => { + const { container } = render( + {}} />, + ); + + const video = container.querySelector("video"); + expect(video?.getAttribute("src")).toBe( + "https://mdn.github.io/shared-assets/videos/flower.mp4", + ); + // 재생 제어는 브라우저 네이티브 controls에 위임 (스펙 뷰 5) + expect(video?.hasAttribute("controls")).toBe(true); + expect(screen.getByText("거리 야경 감성 스팟")).toBeTruthy(); + expect(screen.getByText(/조회 1\.2만/)).toBeTruthy(); + }); + + it("내 영상이면 '내 영상 · M월 D일', 타인이면 @핸들 소유 문구가 보인다 — 카드와 동일 분기 (AC 5, 추정 5)", () => { + const { rerender } = render( + {}} />, + ); + expect(screen.getByText(/내 영상/)).toBeTruthy(); + expect(screen.getByText(/7월 15일/)).toBeTruthy(); + + rerender( {}} />); + expect(screen.getByText(/@jeonpo_alley/)).toBeTruthy(); + }); + + it("닫기 버튼 클릭 시 onClose가 호출된다 (AC 5)", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "미니 패널 닫기" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("열릴 때 포커스가 닫기 버튼으로 이동하고, 교체 시에는 이동하지 않는다 (AC 6)", () => { + const { rerender } = render( + {}} />, + ); + + const closeButton = screen.getByRole("button", { name: "미니 패널 닫기" }); + expect(document.activeElement).toBe(closeButton); + + // 사용자가 포커스를 옮긴 뒤 다른 카드로 교체 — 포커스를 다시 빼앗지 않는다 + closeButton.blur(); + rerender( {}} />); + + expect(document.activeElement).not.toBe(closeButton); + }); +}); diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index 306585e0..7cecd8bc 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -67,3 +67,4 @@ | 2026-07-30 | MSG-263 | 결정(개정 2 구조): 상시 점령 셀 파생(`buildOccupiedGridCells(MOCK_DEX.collectedCells)`)을 MapShell 모듈 스코프 상수로 1회 계산 | mock이 정적 데이터라 컴포넌트 스코프 useMemo가 불필요 — 섹션 전환·리렌더 재계산 0회. 점령이 서버 상태가 되는 시점(실 API 전환)에 쿼리 구독 + useMemo로 옮기는 전제를 주석에 남김 | | 2026-07-31 | MSG-277 | 결정(구조): 홈 좌측 패널 Escape 닫기 배선(입력 타깃 무시 계약 포함)을 `pages/map-home/ui/use-escape-close.ts` 뷰-레이어 훅으로 추출 — HomeCellDetailPanel·ThemeFeedPanel 공용 | 테마 피드에 Escape를 배선(추정 6)하면 동일 10줄 effect가 두 패널에 중복됨 — 두 번째 사용처 발생 시 추출 규칙 적용. MSG-252 리뷰 반영으로 고정된 "input/textarea 타깃 무시" 계약이 한 곳에 남아 패널 간 드리프트를 막음 (기존 Escape 스모크 2케이스 GREEN 불변) | | 2026-07-31 | MSG-277 | 결정(2차 스펙 해석): 시간대 그래프의 집계 표본을 화면 피드와 동일한 `myVideos+otherVideos`로 통일 — 비테마 점령 상세는 내 영상만 집계 | AC 9(순수 함수 계약)는 `cell.videos` 합 단정, AC 10(화면)은 "N = 피드 표본 수"를 요구 — 비테마 상세는 피드가 내 영상만 나열하므로 cell.videos 전체를 집계하면 그래프 합과 "영상 N개 기준" 문구가 어긋남. 함수는 입력 중립(테스트는 cell.videos로 계약 단정)이고 표본 선택은 뷰가 피드와 공유해 문구-그래프 정합 유지 | +| 2026-07-31 | MSG-277 | 결정(3차 스펙 해석): 미니 패널 videoSrc 목 소스를 CC0 샘플 2종 순환으로 채택(스펙 허용 범위 2~3종의 하한) — `mdn.github.io/shared-assets`의 flower.mp4·friday.mp4, 3종 후보였던 sintel-short.mp4는 제외 | MDN cc0-videos 세트에서 실로드 확인(HTTP 206, video/mp4)된 mp4는 2종뿐이고 sintel은 CC-BY 3.0(Blender)이라 티켓의 "CC0 샘플" 정책 위배. webm 추가는 Safari 재생 신뢰성을 깎아 제외 — 순환 검증 테스트(mock-cells.test)는 "2종 이상"만 단정해 소스 추가에 열려 있음 | From 09030441cea81f73ad1f27b32c608d12ae7b03ff Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 1 Aug 2026 14:35:54 +0900 Subject: [PATCH 111/281] =?UTF-8?q?MSG-277=20fix:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20=EA=B0=99=EC=9D=80=20=EC=85=80=20?= =?UTF-8?q?=EC=9E=AC=ED=83=AD=20=EC=8B=9C=20=EB=AF=B8=EB=8B=88=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=9C=A0=EC=A7=80=C2=B7=EC=86=8C=EC=9C=A0=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=20=EA=B3=B5=EC=9A=A9=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map-home/model/home-cell-detail-store.ts | 4 ++- .../model/video-mini-panel-store.test.ts | 9 ++++++ .../src/pages/map-home/ui/FeedVideoCard.tsx | 25 ++------------- .../src/pages/map-home/ui/VideoMiniPanel.tsx | 27 +++------------- .../src/pages/map-home/ui/VideoOwnerMeta.tsx | 31 +++++++++++++++++++ 5 files changed, 50 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/pages/map-home/ui/VideoOwnerMeta.tsx diff --git a/apps/web/src/features/map-home/model/home-cell-detail-store.ts b/apps/web/src/features/map-home/model/home-cell-detail-store.ts index f268de39..6db58d30 100644 --- a/apps/web/src/features/map-home/model/home-cell-detail-store.ts +++ b/apps/web/src/features/map-home/model/home-cell-detail-store.ts @@ -20,9 +20,11 @@ interface HomeCellDetailState { * theme-filter→detail cross-store 관례). * 플랫폼 API(window/localStorage/router)를 참조하지 않는다 — RN 경계. */ -export const useHomeCellDetailStore = create((set) => ({ +export const useHomeCellDetailStore = create((set, get) => ({ selectedCellId: null, select: (cellId) => { + // 같은 셀 재탭은 컨텍스트 불변 — 미니 패널을 닫을 근거가 없다 (리뷰 반영) + if (get().selectedCellId === cellId) return; useVideoMiniPanelStore.getState().close(); set({ selectedCellId: cellId }); }, diff --git a/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts b/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts index 89cf21a9..2ee3a7b6 100644 --- a/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts +++ b/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts @@ -84,6 +84,15 @@ describe("useVideoMiniPanelStore — 컨텍스트 닫힘 연동 (3차 AC 2·3)", expect(useVideoMiniPanelStore.getState().selected).toBeNull(); }); + it("같은 셀 재선택(select) 시 미니 패널 선택이 유지된다 — 상세 컨텍스트 불변 (리뷰 반영)", () => { + useHomeCellDetailStore.getState().select("A-14"); + useVideoMiniPanelStore.getState().open(VIDEO_A, true); + + useHomeCellDetailStore.getState().select("A-14"); + + expect(useVideoMiniPanelStore.getState().selected).not.toBeNull(); + }); + // 아래 3케이스는 내부 배선(toggle→detail.close 체인)과 무관하게 결과 계약만 단정한다 (스펙 신규 로직 3) it("테마 칩 해제(toggle) 시 미니 패널 선택이 닫힌다 (AC 2)", () => { useThemeFilterStore.getState().toggle("hot"); diff --git a/apps/web/src/pages/map-home/ui/FeedVideoCard.tsx b/apps/web/src/pages/map-home/ui/FeedVideoCard.tsx index 3948c39c..b7d6171e 100644 --- a/apps/web/src/pages/map-home/ui/FeedVideoCard.tsx +++ b/apps/web/src/pages/map-home/ui/FeedVideoCard.tsx @@ -1,11 +1,8 @@ import { Play } from "lucide-react"; import type { CellVideo } from "@/entities/cell"; import { formatDuration } from "@/features/explore/model/explore-cells"; -import { - formatMonthDay, - formatRelativeTime, - formatViewCountKo, -} from "@/shared/format"; +import { formatViewCountKo } from "@/shared/format"; +import { VideoOwnerMeta } from "./VideoOwnerMeta"; interface FeedVideoCardProps { video: CellVideo; @@ -41,23 +38,7 @@ export const FeedVideoCard = ({ video, mine, onSelect }: FeedVideoCardProps) => )}
- {mine ? ( - - 내 영상 - {` · ${formatMonthDay(video.uploadedAt)}`} - - ) : ( - - {video.uploaderHandle && ( - - {video.uploaderHandle} - - )} - {video.uploaderHandle - ? ` · ${formatRelativeTime(video.uploadedAt)}` - : formatRelativeTime(video.uploadedAt)} - - )} + 조회 {formatViewCountKo(video.viewCount)} diff --git a/apps/web/src/pages/map-home/ui/VideoMiniPanel.tsx b/apps/web/src/pages/map-home/ui/VideoMiniPanel.tsx index c3a52b2f..4a90d9a0 100644 --- a/apps/web/src/pages/map-home/ui/VideoMiniPanel.tsx +++ b/apps/web/src/pages/map-home/ui/VideoMiniPanel.tsx @@ -1,11 +1,8 @@ import { useEffect, useRef } from "react"; import { Film, X } from "lucide-react"; import type { VideoMiniSelection } from "@/features/map-home/model/video-mini-panel-store"; -import { - formatMonthDay, - formatRelativeTime, - formatViewCountKo, -} from "@/shared/format"; +import { formatViewCountKo } from "@/shared/format"; +import { VideoOwnerMeta } from "./VideoOwnerMeta"; interface VideoMiniPanelProps { /** 미니 패널 선택 — 영상 데이터 + 내 영상 여부 (video-mini-panel-store) */ @@ -18,7 +15,7 @@ interface VideoMiniPanelProps { * 영상 미니 디테일 패널 (MSG-277 3차 AC 5·6·8~11) — 네이버 지도 PC 보조 패널 방식. * 좌측 패널(w-97) 오른쪽에 flush로 붙는 전고 보조 패널 — 배경 지도 조작은 차단하지 않는다 * (백드롭 없음). 구성: 닫기 버튼 → 재생 영역(HTML5 video controls, videoSrc 없으면 Film - * 플레이스홀더) → 메타(제목·소유 문구·시간·조회수 — FeedVideoCard와 동일 mine 분기, 추정 5). + * 플레이스홀더) → 메타(제목·소유 문구·시간·조회수 — 소유 문구는 VideoOwnerMeta 공용, 추정 5). * 열릴 때(마운트) 포커스를 닫기 버튼으로 옮기고, 카드 교체(리렌더)에는 옮기지 않는다 (AC 6). * 접힘 시에는 셸의 display:none 래퍼로 좌측 패널과 함께 숨는다 (추정 9). */ @@ -73,23 +70,7 @@ export const VideoMiniPanel = ({ selected, onClose }: VideoMiniPanelProps) => {

{video.title}

- {mine ? ( - - 내 영상 - {` · ${formatMonthDay(video.uploadedAt)}`} - - ) : ( - - {video.uploaderHandle && ( - - {video.uploaderHandle} - - )} - {video.uploaderHandle - ? ` · ${formatRelativeTime(video.uploadedAt)}` - : formatRelativeTime(video.uploadedAt)} - - )} + 조회 {formatViewCountKo(video.viewCount)} diff --git a/apps/web/src/pages/map-home/ui/VideoOwnerMeta.tsx b/apps/web/src/pages/map-home/ui/VideoOwnerMeta.tsx new file mode 100644 index 00000000..ddd43a4b --- /dev/null +++ b/apps/web/src/pages/map-home/ui/VideoOwnerMeta.tsx @@ -0,0 +1,31 @@ +import type { CellVideo } from "@/entities/cell"; +import { formatMonthDay, formatRelativeTime } from "@/shared/format"; + +interface VideoOwnerMetaProps { + video: CellVideo; + /** 내 영상 여부 — 문구 분기: 내 영상 "내 영상 · M월 D일" / 다른 사용자 "@핸들 · 상대시간" */ + mine: boolean; +} + +/** + * 영상 소유 메타 한 줄 (MSG-277 AC 4·5) — FeedVideoCard·VideoMiniPanel 공용. + * 카드와 그 카드로 연 미니 패널이 같은 영상의 동일 표기를 보여야 해서 분기를 한곳에 둔다 (리뷰 반영). + */ +export const VideoOwnerMeta = ({ video, mine }: VideoOwnerMetaProps) => + mine ? ( + + 내 영상 + {` · ${formatMonthDay(video.uploadedAt)}`} + + ) : ( + + {video.uploaderHandle && ( + + {video.uploaderHandle} + + )} + {video.uploaderHandle + ? ` · ${formatRelativeTime(video.uploadedAt)}` + : formatRelativeTime(video.uploadedAt)} + + ); From e438a857850529051c8f3fb9e7d6dd25af1d030a Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 1 Aug 2026 17:36:10 +0900 Subject: [PATCH 112/281] =?UTF-8?q?MSG-264=20feat:=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80=20=EC=A4=8C=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=C2=B7=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0=20=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20-=20250m=20=EC=B4=88=EA=B3=BC=20?= =?UTF-8?q?=EC=B6=95=EC=B2=99=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=ED=81=B4?= =?UTF-8?q?=EB=9F=AC=EC=8A=A4=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map-home/model/cluster-overlay.test.ts | 223 ++++++++++++++++++ .../map-home/model/cluster-overlay.ts | 150 ++++++++++++ .../features/map-home/model/grid-overlay.ts | 8 +- apps/web/src/pages/map-home/ui/MapCanvas.tsx | 80 ++++++- apps/web/src/widgets/map-shell/MapShell.tsx | 24 +- 5 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/features/map-home/model/cluster-overlay.test.ts create mode 100644 apps/web/src/features/map-home/model/cluster-overlay.ts diff --git a/apps/web/src/features/map-home/model/cluster-overlay.test.ts b/apps/web/src/features/map-home/model/cluster-overlay.test.ts new file mode 100644 index 00000000..8a2390ad --- /dev/null +++ b/apps/web/src/features/map-home/model/cluster-overlay.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { + GRID_ORIGIN, + cellBoundsAt, + cellIndexAt, + type Bounds, + type LatLng, +} from "@/entities/cell"; +import { MOCK_DEX } from "@/entities/dex"; +import { + buildClusterMarkers, + clusterWindowSteps, + gateFillCells, + selectClusterSource, + tierOf, + type ClusterMarker, +} from "./cluster-overlay"; +import { + GRID_MIN_ZOOM, + buildGridLines, + buildOccupiedGridCells, +} from "./grid-overlay"; +import type { StyledCellOverlay } from "./theme-overlay"; + +/** 서면 일대 뷰포트 — grid-overlay.test와 동일 기준 (AC 2 격자선 게이트 단정용) */ +const SEOMYEON_VIEWPORT: Bounds = { + sw: { lat: 35.153, lng: 129.053 }, + ne: { lat: 35.163, lng: 129.065 }, +}; + +/** 셸과 동일 입력 경로의 상시 점령 셀 — 경계 내부 필터 완료본 (MSG-263 D3·D9) */ +const PERSISTENT = buildOccupiedGridCells(MOCK_DEX.collectedCells); + +/** center가 속한 100m 격자 셀로 스냅된 오버레이 셀 — 파생물 입력 형태와 동일 */ +const overlayAt = ( + id: string, + center: LatLng, + color?: string, +): StyledCellOverlay => ({ + id, + bounds: cellBoundsAt(cellIndexAt(center)), + ...(color !== undefined && { color }), +}); + +/** 서면 일대에 600~700m 간격으로 흩어진 합성 셀 3×3 — 전부 부산 행정경계 내부 */ +const SEOMYEON_SPREAD: StyledCellOverlay[] = Array.from({ length: 9 }, (_, i) => + overlayAt(`S-${i}`, { + lat: 35.152 + Math.floor(i / 3) * 0.006, + lng: 129.054 + (i % 3) * 0.007, + }), +); + +const ALL_CELLS = [...PERSISTENT, ...SEOMYEON_SPREAD]; + +const sumCount = (markers: ClusterMarker[]): number => + markers.reduce((sum, m) => sum + m.count, 0); + +describe("gateFillCells — 채움 줌 게이트 (MSG-264 AC 1·2, A5 전 섹션 공유)", () => { + it("zoom 15(이상)에서는 채움 셀 목록이 그대로 반환된다 (AC 1)", () => { + expect(gateFillCells(ALL_CELLS, GRID_MIN_ZOOM)).toEqual(ALL_CELLS); + expect(gateFillCells(ALL_CELLS, 16)).toEqual(ALL_CELLS); + }); + + it("zoom 14(미만)에서는 채움 셀이 파생되지 않는다 (AC 2)", () => { + expect(gateFillCells(ALL_CELLS, 14)).toEqual([]); + expect(gateFillCells(ALL_CELLS, 10)).toEqual([]); + }); +}); + +describe("buildClusterMarkers — 그리드 윈도 클러스터링 (MSG-264)", () => { + it("zoom 15(이상) 입력에는 클러스터가 파생되지 않는다 (AC 1)", () => { + expect(buildClusterMarkers(ALL_CELLS, GRID_MIN_ZOOM)).toEqual([]); + expect(buildClusterMarkers(ALL_CELLS, 16)).toEqual([]); + }); + + it("zoom 14에서는 클러스터가 파생되고 채움 셀·격자선은 파생되지 않는다 — 경계값 15/14 양쪽 (AC 2)", () => { + expect(buildClusterMarkers(ALL_CELLS, 14).length).toBeGreaterThan(0); + expect(gateFillCells(ALL_CELLS, 14)).toEqual([]); + expect(buildGridLines(SEOMYEON_VIEWPORT, 14)).toEqual([]); + }); + + it("클러스터 배지 숫자의 합 = 집계 대상 셀 수 — 어떤 셀도 누락·중복 집계되지 않는다 (AC 4)", () => { + for (const zoom of [14, 13, 12]) { + expect(sumCount(buildClusterMarkers(ALL_CELLS, zoom))).toBe( + ALL_CELLS.length, + ); + } + }); + + it("집계 대상 셀이 0개면 클러스터 마커도 0개다 (AC 10)", () => { + expect(buildClusterMarkers([], 13)).toEqual([]); + }); + + it("부산 행정경계 밖 center 셀은 어떤 클러스터 count에도 들어가지 않는다 (AC 11)", () => { + const withSeaCell = [ + ...ALL_CELLS, + overlayAt("SEA-1", { lat: 34.95, lng: 129.0 }), + ]; + expect(sumCount(buildClusterMarkers(withSeaCell, 13))).toBe( + ALL_CELLS.length, + ); + }); + + it("줌 1단 아웃 시 윈도가 2배로 커져 재묶기된다 — zoom 13 같은 윈도의 두 셀이 zoom 14에서는 분리 (AC 6)", () => { + const s13 = clusterWindowSteps(13); + const s14 = clusterWindowSteps(14); + expect(s13.lat).toBeCloseTo(2 * s14.lat, 12); + expect(s13.lng).toBeCloseTo(2 * s14.lng, 12); + + // 서면 근방의 zoom 13 윈도 하나를 골라 그 안 0.25·0.75 지점에 셀 배치 — + // zoom 14 윈도(절반 크기)로는 서로 다른 윈도에 떨어진다 + const row = Math.round((35.157 - GRID_ORIGIN.lat) / s13.lat); + const col = Math.round((129.059 - GRID_ORIGIN.lng) / s13.lng); + const lng = GRID_ORIGIN.lng + (col + 0.5) * s13.lng; + const pair = [ + overlayAt("P-1", { lat: GRID_ORIGIN.lat + (row + 0.25) * s13.lat, lng }), + overlayAt("P-2", { lat: GRID_ORIGIN.lat + (row + 0.75) * s13.lat, lng }), + ]; + + expect(buildClusterMarkers(pair, 14)).toHaveLength(2); + const merged = buildClusterMarkers(pair, 13); + expect(merged).toHaveLength(1); + expect(merged[0].count).toBe(2); + + // 클릭 줌 인 대상 bounds = 멤버 셀 bounds 합집합 + expect(merged[0].bounds.sw.lat).toBeCloseTo(pair[0].bounds.sw.lat, 10); + expect(merged[0].bounds.ne.lat).toBeCloseTo(pair[1].bounds.ne.lat, 10); + expect(merged[0].bounds.sw.lng).toBeCloseTo(pair[0].bounds.sw.lng, 10); + expect(merged[0].bounds.ne.lng).toBeCloseTo(pair[0].bounds.ne.lng, 10); + + // 전체 셀에서도 줌 아웃이 클러스터 수를 늘리지 않는다 + expect(buildClusterMarkers(ALL_CELLS, 12).length).toBeLessThanOrEqual( + buildClusterMarkers(ALL_CELLS, 14).length, + ); + }); + + it("같은 줌 마커 쌍 사이 거리가 윈도 절반 이상이고 마커는 윈도 중앙부(1/2 영역)에 클램프된다 (AC 7, A1)", () => { + for (const zoom of [14, 13, 12]) { + const markers = buildClusterMarkers(ALL_CELLS, zoom); + const step = clusterWindowSteps(zoom); + + for (const marker of markers) { + // 윈도 내 위치 비율 — 중앙 1/2 영역([0.25, 0.75]) 클램프 + const fracLat = + (marker.position.lat - GRID_ORIGIN.lat) / step.lat - + Math.floor((marker.position.lat - GRID_ORIGIN.lat) / step.lat); + const fracLng = + (marker.position.lng - GRID_ORIGIN.lng) / step.lng - + Math.floor((marker.position.lng - GRID_ORIGIN.lng) / step.lng); + expect(fracLat).toBeGreaterThanOrEqual(0.25 - 1e-9); + expect(fracLat).toBeLessThanOrEqual(0.75 + 1e-9); + expect(fracLng).toBeGreaterThanOrEqual(0.25 - 1e-9); + expect(fracLng).toBeLessThanOrEqual(0.75 + 1e-9); + } + + // 겹침 방지 최소 간격 — 어느 마커 쌍도 정규화 체비쇼프 거리 0.5 윈도 미만으로 붙지 않는다 + for (let i = 0; i < markers.length; i++) { + for (let j = i + 1; j < markers.length; j++) { + const dLat = + Math.abs(markers[i].position.lat - markers[j].position.lat) / + step.lat; + const dLng = + Math.abs(markers[i].position.lng - markers[j].position.lng) / + step.lng; + expect(Math.max(dLat, dLng)).toBeGreaterThanOrEqual(0.5 - 1e-9); + } + } + } + }); +}); + +describe("tierOf — 묶인 수의 3단계 tier 매핑 (MSG-264 AC 5, A2)", () => { + it("1~2개 = tier 1, 3~5개 = tier 2, 6개 이상 = tier 3", () => { + expect(tierOf(1)).toBe(1); + expect(tierOf(2)).toBe(1); + expect(tierOf(3)).toBe(2); + expect(tierOf(5)).toBe(2); + expect(tierOf(6)).toBe(3); + expect(tierOf(40)).toBe(3); + }); + + it("파생된 모든 마커의 tier가 count의 tierOf와 일치한다", () => { + const markers = buildClusterMarkers(ALL_CELLS, 12); + expect(markers.length).toBeGreaterThan(0); + for (const marker of markers) { + expect(marker.tier).toBe(tierOf(marker.count)); + } + }); +}); + +describe("selectClusterSource — 집계 소스 선택 (MSG-264 AC 9)", () => { + const themedCells = [ + overlayAt("T-1", { lat: 35.156, lng: 129.058 }, "#E8590C"), + overlayAt("T-2", { lat: 35.159, lng: 129.061 }, "#E8590C"), + ]; + + it("섹션 게시 셀이 있으면 그 셀 기준으로 집계하고 첫 셀의 테마 색을 전승한다", () => { + expect(selectClusterSource(themedCells, PERSISTENT)).toEqual({ + cells: themedCells, + color: "#E8590C", + }); + }); + + it("게시 셀이 없으면 상시 점령 셀 기준·color 미지정(primary)이다", () => { + const source = selectClusterSource([], PERSISTENT); + expect(source.cells).toBe(PERSISTENT); + expect(source.color).toBeUndefined(); + }); + + it("마커 색은 멤버 셀의 테마 색을 전승하고, 점령 셀 소스 마커는 color 미지정(primary)이다", () => { + const themedMarkers = buildClusterMarkers(themedCells, 13); + expect(themedMarkers.length).toBeGreaterThan(0); + for (const marker of themedMarkers) { + expect(marker.color).toBe("#E8590C"); + } + + const occupiedMarkers = buildClusterMarkers(PERSISTENT, 13); + expect(occupiedMarkers.length).toBeGreaterThan(0); + for (const marker of occupiedMarkers) { + expect(marker.color).toBeUndefined(); + } + }); +}); diff --git a/apps/web/src/features/map-home/model/cluster-overlay.ts b/apps/web/src/features/map-home/model/cluster-overlay.ts new file mode 100644 index 00000000..b64ade92 --- /dev/null +++ b/apps/web/src/features/map-home/model/cluster-overlay.ts @@ -0,0 +1,150 @@ +import { + GRID_LAT_STEP, + GRID_LNG_STEP, + GRID_ORIGIN, + type Bounds, + type LatLng, +} from "@/entities/cell"; +import { GRID_MIN_ZOOM, isGridCellCenterInBusan } from "./grid-overlay"; +import type { StyledCellOverlay } from "./theme-overlay"; + +/** + * 클러스터 오버레이 파생 (MSG-264). + * 순수 함수 — 지도 SDK/플랫폼에 의존하지 않는다(RN 재사용 대상). + * zoom < GRID_MIN_ZOOM에서 격자 채움 대신 원형 배지 + 격자 수 마커로 전환한다 — + * MSG-263 D4("점령 채움은 임계와 무관하게 항상 표시")를 명시적으로 대체한다. + * 렌더링(naver Marker + HtmlIcon)·클릭 fitBounds는 MapCanvas 경계 안에서 하고, 여기는 데이터만 만든다. + */ + +/** + * 클러스터 윈도의 격자 셀 배수 계수 (A1) — 윈도 지상 폭 = 격자 스텝 × 2^(GRID_MIN_ZOOM − zoom) × 계수. + * 줌 1단 아웃 = 지상 폭 2배이므로 윈도의 픽셀 등가 크기는 줌과 무관하게 일정하다: + * zoom 15에서 100m ≈ 26px(위도 35°) → 3.5배 ≈ 90px. 마커 최대 지름(tier 3, 44px)의 + * 2배 이상이라 중앙부 클램프와 함께 마커 겹침이 없다 (AC 7). + */ +export const CLUSTER_WINDOW_CELL_FACTOR = 3.5; + +/** 줌별 클러스터 윈도 스텝(도 단위) — 줌 1단 아웃마다 2배 (AC 6) */ +export const clusterWindowSteps = ( + zoom: number, +): { lat: number; lng: number } => { + const scale = CLUSTER_WINDOW_CELL_FACTOR * 2 ** (GRID_MIN_ZOOM - zoom); + return { lat: GRID_LAT_STEP * scale, lng: GRID_LNG_STEP * scale }; +}; + +/** 묶인 격자 수 → 배지 크기 3단계 (AC 5, A2 — mock 규모 기준, 실 API 규모에서 재조정 전제) */ +export const tierOf = (count: number): 1 | 2 | 3 => + count <= 2 ? 1 : count <= 5 ? 2 : 3; + +/** 지도에 게시할 클러스터 마커 — 순수 데이터, MapCanvas prop 계약. bounds는 클릭 줌 인 대상(멤버 셀 합집합) */ +export interface ClusterMarker { + id: string; + position: LatLng; + count: number; + tier: 1 | 2 | 3; + /** 배지 색 (테마 토큰 hex) — 미지정 시 primary (AC 9) */ + color?: string; + bounds: Bounds; +} + +/** + * 채움 셀 줌 게이트 (AC 1·2, A5 — 전 섹션 공유) — 셸 합성 계층에서 병합된 오버레이 셀에 적용한다. + * zoom < GRID_MIN_ZOOM이면 빈 배열 = MapCanvas에 채움 미전달(기존 동작 불변 계약). + */ +export const gateFillCells = ( + cells: StyledCellOverlay[], + zoom: number, +): StyledCellOverlay[] => (zoom >= GRID_MIN_ZOOM ? cells : []); + +/** 클러스터 집계 소스 (AC 9) — 섹션 게시 셀이 있으면 그것(첫 셀 테마 색 전승), 없으면 상시 점령 셀(primary) */ +export const selectClusterSource = ( + sectionCells: StyledCellOverlay[], + persistentCells: StyledCellOverlay[], +): { cells: StyledCellOverlay[]; color?: string } => + sectionCells.length > 0 + ? { cells: sectionCells, color: sectionCells[0].color } + : { cells: persistentCells }; + +const clampTo = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +/** + * 채움 셀 → 그리드 윈도 클러스터 마커 목록 (AC 4·6·7·10·11). + * - zoom ≥ GRID_MIN_ZOOM이면 빈 배열 — 채움이 표시되는 줌에서는 클러스터가 없다 (AC 1) + * - 셀 bounds 중심을 윈도(GRID_ORIGIN 기준)에 스냅해 묶는다 — 누락·중복 없는 분할 (AC 4) + * - 마커 위치 = 멤버 centroid를 윈도 중앙 1/2 영역으로 클램프 — 인접 마커 최소 간격 + * = 윈도 절반 보장 (AC 7, A1) + * - 부산 행정경계 밖 center 셀은 집계 제외 (AC 11 — 파생물 입력이 이미 필터본이어도 재보장) + */ +export const buildClusterMarkers = ( + cells: StyledCellOverlay[], + zoom: number, +): ClusterMarker[] => { + if (zoom >= GRID_MIN_ZOOM) return []; + + const step = clusterWindowSteps(zoom); + const windows = new Map< + string, + { col: number; row: number; members: { cell: StyledCellOverlay; center: LatLng }[] } + >(); + + for (const cell of cells) { + const center = { + lat: (cell.bounds.sw.lat + cell.bounds.ne.lat) / 2, + lng: (cell.bounds.sw.lng + cell.bounds.ne.lng) / 2, + }; + if (!isGridCellCenterInBusan(center)) continue; + + const col = Math.floor((center.lng - GRID_ORIGIN.lng) / step.lng); + const row = Math.floor((center.lat - GRID_ORIGIN.lat) / step.lat); + const key = `${col}:${row}`; + const found = windows.get(key); + if (found) found.members.push({ cell, center }); + else windows.set(key, { col, row, members: [{ cell, center }] }); + } + + return [...windows.values()].map(({ col, row, members }) => { + const centroid = { + lat: members.reduce((s, m) => s + m.center.lat, 0) / members.length, + lng: members.reduce((s, m) => s + m.center.lng, 0) / members.length, + }; + const windowCenter = { + lat: GRID_ORIGIN.lat + (row + 0.5) * step.lat, + lng: GRID_ORIGIN.lng + (col + 0.5) * step.lng, + }; + const bounds = members.reduce( + (acc, { cell }) => ({ + sw: { + lat: Math.min(acc.sw.lat, cell.bounds.sw.lat), + lng: Math.min(acc.sw.lng, cell.bounds.sw.lng), + }, + ne: { + lat: Math.max(acc.ne.lat, cell.bounds.ne.lat), + lng: Math.max(acc.ne.lng, cell.bounds.ne.lng), + }, + }), + members[0].cell.bounds, + ); + const color = members[0].cell.color; + + return { + id: `cluster-${col}:${row}`, + position: { + lat: clampTo( + centroid.lat, + windowCenter.lat - step.lat / 4, + windowCenter.lat + step.lat / 4, + ), + lng: clampTo( + centroid.lng, + windowCenter.lng - step.lng / 4, + windowCenter.lng + step.lng / 4, + ), + }, + count: members.length, + tier: tierOf(members.length), + ...(color !== undefined && { color }), + bounds, + }; + }); +}; diff --git a/apps/web/src/features/map-home/model/grid-overlay.ts b/apps/web/src/features/map-home/model/grid-overlay.ts index 9bb7a1b3..90014a0b 100644 --- a/apps/web/src/features/map-home/model/grid-overlay.ts +++ b/apps/web/src/features/map-home/model/grid-overlay.ts @@ -24,7 +24,11 @@ import type { OccupiedCell, StyledCellOverlay } from "./theme-overlay"; * 파생한다 — 도형 수가 셀 수(열×행)가 아닌 선 수(열+행 × 경계 분절) 규모다 [AC 5]. */ -/** 격자선 표시 최소 줌 (D4) — 미만이면 격자선 숨김. 점령 채움은 임계와 무관하게 항상 표시 */ +/** + * 격자·채움 표시 최소 줌 — 미만이면 격자선과 점령·테마 채움을 모두 숨기고 클러스터 마커로 + * 전환한다 (MSG-264 — D4 "점령 채움은 임계와 무관하게 항상 표시"를 명시적으로 대체). + * 채움 게이트·클러스터 파생은 cluster-overlay(gateFillCells·buildClusterMarkers) 소유. + */ export const GRID_MIN_ZOOM = 15; /** 지도에 게시할 격자선 한 선분 — 순수 데이터(id + 두 끝점), MapCanvas prop 계약 */ @@ -48,7 +52,7 @@ const intersectBounds = (a: Bounds, b: Bounds): Bounds | null => { /** * 뷰포트 → 부산 행정경계로 절단된 점선 격자선 선분 목록. [AC 2·3·5·6] - * - 줌 게이트: GRID_MIN_ZOOM 미만이면 빈 배열 (D4) + * - 줌 게이트: GRID_MIN_ZOOM 미만이면 빈 배열 — 채움·클러스터 전환 게이트(MSG-264, cluster-overlay)와 임계 공유 * - 뷰포트 컬링 + 한 화면 버퍼: 드래그 중 빈 영역 노출을 줄이기 위해 각 방향 1화면 여유(R3) * - 부산 bbox 교집합 밖이면 빈 배열, 경계 절단은 clipLineToBoundary(스캔라인) [D7] */ diff --git a/apps/web/src/pages/map-home/ui/MapCanvas.tsx b/apps/web/src/pages/map-home/ui/MapCanvas.tsx index 0b8905a0..7be5b0fa 100644 --- a/apps/web/src/pages/map-home/ui/MapCanvas.tsx +++ b/apps/web/src/pages/map-home/ui/MapCanvas.tsx @@ -18,6 +18,7 @@ import { import { semantic } from "@fillmap/design-tokens"; import { Button } from "@fillmap/ui-web"; import type { Bounds, LatLng } from "@/entities/cell"; +import { GRID_MIN_ZOOM } from "@/features/map-home/model/grid-overlay"; import { MAX_ZOOM, MIN_ZOOM } from "@/features/map-home/model/map-scale"; import { buildHatchLines } from "@/features/map-home/model/theme-overlay"; import type { Viewport } from "@/features/map-home/model/viewport-store"; @@ -71,6 +72,21 @@ export interface MapRouteOverlay { color: string; } +/** + * 지도에 그릴 클러스터 마커 한 개 — 순수 데이터 (MSG-264). 파생(윈도 묶기·클램프)은 호출부 몫. + * bounds는 멤버 셀 합집합 — 클릭 줌 인(fitBounds) 대상. + */ +export interface MapClusterOverlay { + id: string; + position: LatLng; + count: number; + /** 배지 크기 3단계 (AC 5, A6 — 크기 단계 채택) */ + tier: 1 | 2 | 3; + /** 배지 색 (테마 토큰 hex) — 미지정 시 primary (AC 9) */ + color?: string; + bounds: Bounds; +} + interface MapCanvasProps { /** 초기 중심 좌표 (geolocation 결과 반영) */ center: LatLng; @@ -82,6 +98,8 @@ interface MapCanvasProps { gridLines?: MapGridLine[]; /** 경로 오버레이 (MSG-252 AC 8) — 미제공이면 기존 동작과 동일 */ route?: MapRouteOverlay; + /** 클러스터 마커 목록 (MSG-264) — 미제공/빈 배열이면 기존 동작과 동일 */ + clusters?: MapClusterOverlay[]; /** 오버레이 셀 클릭 (MSG-122 AC 14·18) — 미제공이면 표시 전용 기존 동작과 동일(R3) */ onOverlayCellClick?: (cellId: string) => void; } @@ -171,7 +189,15 @@ class MapLoadErrorBoundary extends Component< */ export const MapCanvas = forwardRef( ( - { center, onViewportChange, overlayCells, gridLines, route, onOverlayCellClick }, + { + center, + onViewportChange, + overlayCells, + gridLines, + route, + clusters, + onOverlayCellClick, + }, ref, ) => { // 재시도 시 로드 경계·인증 상태를 다시 태우기 위해 하위 뷰를 remount @@ -199,6 +225,7 @@ export const MapCanvas = forwardRef( overlayCells={overlayCells} gridLines={gridLines} route={route} + clusters={clusters} onOverlayCellClick={onOverlayCellClick} onRetry={retry} /> @@ -250,6 +277,23 @@ const boundsToPath = ({ sw, ne }: Bounds): LatLng[] => [ const routeMarkerContent = (seq: number): string => `
${seq}
`; +// 클러스터 배지 크기 3단계 (MSG-264 AC 5, A6 — 크기 단계 채택). 최대 지름(tier 3, 44px)이 +// 클러스터 윈도 픽셀 등가(약 90px — cluster-overlay CLUSTER_WINDOW_CELL_FACTOR)의 절반 +// 이하라 중앙부 클램프(A1)와 함께 인접 마커가 겹치지 않는다 (AC 7) +const CLUSTER_TIER_SIZE_CLASS: Record = { + 1: "size-7", + 2: "size-9", + 3: "size-11", +}; + +/** + * 클러스터 원형 배지 HTML (MSG-264 AC 3·5·9) — routeMarkerContent 선례를 따른 HtmlIcon content. + * 배지 색은 데이터로 받은 테마 토큰 hex(미지정 시 primary) — Polygon fillColor와 같은 관례라 + * inline style로 지정한다(tailwind 색 임의값 클래스 금지 준수). count는 파생 로직의 숫자 전제. + */ +const clusterMarkerContent = ({ count, tier, color }: MapClusterOverlay): string => + `
${count}
`; + const NaverMapView = forwardRef( ( { @@ -260,6 +304,7 @@ const NaverMapView = forwardRef( overlayCells, gridLines, route, + clusters, onOverlayCellClick, onRetry, }, @@ -338,6 +383,28 @@ const NaverMapView = forwardRef( if (map) onViewportChange(toViewport(map)); }; + // 클러스터 클릭 줌 인 (MSG-264 AC 8, A3) — SDK 접근은 이 경계 안에서만. + // 멤버 영역(bounds 합집합)으로 fitBounds하되, 단일 셀 클러스터는 100m 셀 과확대 + // (zoom 21)를 막기 위해 셀 중심으로 GRID_MIN_ZOOM(15) 수준 줌 인한다. + const zoomToCluster = (cluster: MapClusterOverlay) => { + const map = mapRef.current; + if (!map) return; + const { sw, ne } = cluster.bounds; + if (cluster.count === 1) { + map.morph( + new naver.maps.LatLng((sw.lat + ne.lat) / 2, (sw.lng + ne.lng) / 2), + GRID_MIN_ZOOM, + ); + return; + } + map.fitBounds( + new naver.maps.LatLngBounds( + new naver.maps.LatLng(sw.lat, sw.lng), + new naver.maps.LatLng(ne.lat, ne.lng), + ), + ); + }; + // 폴백 전환은 경계 내부 children 교체로만 한다 — 경계가 언마운트 순간의 라이브러리 // cleanup 예외(naver.maps null화)를 흡수해야 하므로 마운트 상태를 유지한다 (AC 8) const failed = authFailed || sdkStatus === "failed"; @@ -432,6 +499,17 @@ const NaverMapView = forwardRef( /> )), )} + {/* 클러스터 배지 마커 (MSG-264 AC 3·5·8) — zoom < GRID_MIN_ZOOM에서 셸이 + 채움 대신 게시한다. title은 배지 숫자의 스크린리더 대체 텍스트(a11y) */} + {clusters?.map((cluster) => ( + zoomToCluster(cluster)} + /> + ))} {/* 경로추천 오버레이 (MSG-252 AC 8) — 연결선 + 번호 경유지 마커 */} {route && ( <> diff --git a/apps/web/src/widgets/map-shell/MapShell.tsx b/apps/web/src/widgets/map-shell/MapShell.tsx index 805a564e..8b75c352 100644 --- a/apps/web/src/widgets/map-shell/MapShell.tsx +++ b/apps/web/src/widgets/map-shell/MapShell.tsx @@ -3,6 +3,11 @@ import { Outlet } from "react-router-dom"; import type { LatLng } from "@/entities/cell"; import { MOCK_DEX } from "@/entities/dex"; import { useMapOverlayStore } from "./map-overlay-store"; +import { + buildClusterMarkers, + gateFillCells, + selectClusterSource, +} from "@/features/map-home/model/cluster-overlay"; import { buildGridLines, buildOccupiedGridCells, @@ -58,6 +63,22 @@ export const MapShell = () => { ], [sectionCells], ); + // 채움 줌 게이트 (MSG-264 AC 1·2, A5 — 전 섹션 공유): zoom < GRID_MIN_ZOOM이면 + // 채움 셀을 전달하지 않고 아래 클러스터로 전환한다 — MSG-263 D4(채움 상시 표시) 대체 + const visibleOverlayCells = useMemo( + () => gateFillCells(overlayCells, viewportZoom), + [overlayCells, viewportZoom], + ); + // 클러스터 파생 (MSG-264 AC 4·9): 섹션 게시 셀이 있으면 그 셀(테마 색), 없으면 상시 + // 점령 셀(primary) 기준으로 집계 — zoom ≥ GRID_MIN_ZOOM이면 빈 배열(게이트 내장) + const clusters = useMemo( + () => + buildClusterMarkers( + selectClusterSource(sectionCells, PERSISTENT_OCCUPIED_CELLS).cells, + viewportZoom, + ), + [sectionCells, viewportZoom], + ); // 오버레이 셀 클릭(MSG-122 AC 14·18) — 핸들러도 스토어 중계, null이면 표시 전용 기존 동작(R3) const onOverlayCellClick = useMapOverlayStore((s) => s.onCellClick); const mapRef = useRef(null); @@ -93,9 +114,10 @@ export const MapShell = () => { ref={mapRef} center={initialCenter} onViewportChange={setViewport} - overlayCells={overlayCells} + overlayCells={visibleOverlayCells} gridLines={gridLines} route={routeOverlay ?? undefined} + clusters={clusters} onOverlayCellClick={onOverlayCellClick ?? undefined} />
From d05282cd6bacdd4e8fe4e36c20f275c2120ad2c3 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 1 Aug 2026 17:54:56 +0900 Subject: [PATCH 113/281] =?UTF-8?q?MSG-264=20fix:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20=EB=B0=B0=EC=A7=80=2099+=20=EC=BA=A1?= =?UTF-8?q?=C2=B7=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0=20=EC=9C=88=EB=8F=84?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=B6=94=EC=B6=9C=C2=B7=ED=81=B4?= =?UTF-8?q?=EB=9E=A8=ED=94=84=20=EB=B9=84=EC=9C=A8=20=EC=83=81=EC=88=98?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map-home/model/cluster-overlay.ts | 23 ++++++++++++------- apps/web/src/pages/map-home/ui/MapCanvas.tsx | 6 ++++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/web/src/features/map-home/model/cluster-overlay.ts b/apps/web/src/features/map-home/model/cluster-overlay.ts index b64ade92..a6b03833 100644 --- a/apps/web/src/features/map-home/model/cluster-overlay.ts +++ b/apps/web/src/features/map-home/model/cluster-overlay.ts @@ -68,6 +68,16 @@ export const selectClusterSource = ( const clampTo = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); +/** 마커를 윈도 중앙 1/2 영역에 클램프하는 반경 비율 (AC 7, A1) */ +const CLUSTER_CENTER_CLAMP_RATIO = 0.25; + +/** 윈도 스냅 묶음 — col·row는 윈도 좌표, members는 묶인 셀과 그 중심 */ +interface ClusterWindow { + col: number; + row: number; + members: { cell: StyledCellOverlay; center: LatLng }[]; +} + /** * 채움 셀 → 그리드 윈도 클러스터 마커 목록 (AC 4·6·7·10·11). * - zoom ≥ GRID_MIN_ZOOM이면 빈 배열 — 채움이 표시되는 줌에서는 클러스터가 없다 (AC 1) @@ -83,10 +93,7 @@ export const buildClusterMarkers = ( if (zoom >= GRID_MIN_ZOOM) return []; const step = clusterWindowSteps(zoom); - const windows = new Map< - string, - { col: number; row: number; members: { cell: StyledCellOverlay; center: LatLng }[] } - >(); + const windows = new Map(); for (const cell of cells) { const center = { @@ -132,13 +139,13 @@ export const buildClusterMarkers = ( position: { lat: clampTo( centroid.lat, - windowCenter.lat - step.lat / 4, - windowCenter.lat + step.lat / 4, + windowCenter.lat - step.lat * CLUSTER_CENTER_CLAMP_RATIO, + windowCenter.lat + step.lat * CLUSTER_CENTER_CLAMP_RATIO, ), lng: clampTo( centroid.lng, - windowCenter.lng - step.lng / 4, - windowCenter.lng + step.lng / 4, + windowCenter.lng - step.lng * CLUSTER_CENTER_CLAMP_RATIO, + windowCenter.lng + step.lng * CLUSTER_CENTER_CLAMP_RATIO, ), }, count: members.length, diff --git a/apps/web/src/pages/map-home/ui/MapCanvas.tsx b/apps/web/src/pages/map-home/ui/MapCanvas.tsx index 7be5b0fa..e6c1b6a7 100644 --- a/apps/web/src/pages/map-home/ui/MapCanvas.tsx +++ b/apps/web/src/pages/map-home/ui/MapCanvas.tsx @@ -286,13 +286,17 @@ const CLUSTER_TIER_SIZE_CLASS: Record = { 3: "size-11", }; +/** 배지 표시 캡 — 고정 크기 원(최대 44px)이라 세 자리부터는 "99+" (카카오·네이버 클러스터러 관례) */ +const formatClusterCount = (count: number): string => + count > 99 ? "99+" : String(count); + /** * 클러스터 원형 배지 HTML (MSG-264 AC 3·5·9) — routeMarkerContent 선례를 따른 HtmlIcon content. * 배지 색은 데이터로 받은 테마 토큰 hex(미지정 시 primary) — Polygon fillColor와 같은 관례라 * inline style로 지정한다(tailwind 색 임의값 클래스 금지 준수). count는 파생 로직의 숫자 전제. */ const clusterMarkerContent = ({ count, tier, color }: MapClusterOverlay): string => - `
${count}
`; + `
${formatClusterCount(count)}
`; const NaverMapView = forwardRef( ( From 71975430bd3e9fd6e7bd08d7adb7337f32961d4c Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 1 Aug 2026 20:29:03 +0900 Subject: [PATCH 114/281] =?UTF-8?q?MSG-289=20setting:=20hey-api=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=A0=A0=20=EB=8F=84=EC=9E=85=20-=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=20=EC=8A=A4=EB=83=85=EC=83=B7=C2=B7=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=83=9D=EC=84=B1=20=EC=9D=B8=ED=94=84=EB=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/web/eslint.config.js | 3 +- apps/web/openapi-ts.config.ts | 22 + apps/web/openapi/api-docs.json | 1 + apps/web/package.json | 2 + apps/web/src/shared/api/generated/index.ts | 3 + .../web/src/shared/api/generated/types.gen.ts | 1878 +++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 218 ++ 8 files changed, 2127 insertions(+), 1 deletion(-) create mode 100644 apps/web/openapi-ts.config.ts create mode 100644 apps/web/openapi/api-docs.json create mode 100644 apps/web/src/shared/api/generated/index.ts create mode 100644 apps/web/src/shared/api/generated/types.gen.ts diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index adfbbcd3..a49d00c4 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -7,7 +7,8 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + // src/shared/api/generated: hey-api 생성 코드(MSG-289) — 수정 대상이 아니라 lint 제외 (typecheck·build에는 포함) + globalIgnores(['dist', 'src/shared/api/generated']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/apps/web/openapi-ts.config.ts b/apps/web/openapi-ts.config.ts new file mode 100644 index 00000000..d4afba72 --- /dev/null +++ b/apps/web/openapi-ts.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@hey-api/openapi-ts"; + +/** + * hey-api 코드젠 설정 (MSG-289) — 백엔드 OpenAPI 명세를 타입 정본으로 삼는다. + * + * [명세 스냅샷과 재생성 절차] + * 1. 명세 갱신: `curl -sS -o openapi/api-docs.json https://api.fillmap.kr/v3/api-docs` + * (apps/web에서 실행. 원문 그대로 저장 — jq 등으로 재정렬하지 않는다) + * 2. 재생성: 루트에서 `pnpm openapi-ts` → `src/shared/api/generated/`에 types.gen.ts 갱신 + * 3. `pnpm typecheck`로 목(entities/*)-명세 불일치를 확인하고 정렬 후 스냅샷·생성물을 함께 커밋 + * + * 입력이 커밋된 로컬 스냅샷이라 백엔드 없이도 재생성·typecheck·build가 성립하고, + * 같은 스냅샷이면 재생성 결과가 결정적이다(git diff 0). + * + * 플러그인은 타입 전용(@hey-api/typescript) — 실제 API 호출 연동(sdk·client·React Query)은 + * 후속 티켓에서 플러그인 추가로 확장한다 (스펙 추정 1 승인). + */ +export default defineConfig({ + input: "./openapi/api-docs.json", + output: "src/shared/api/generated", + plugins: ["@hey-api/typescript"], +}); diff --git a/apps/web/openapi/api-docs.json b/apps/web/openapi/api-docs.json new file mode 100644 index 00000000..0b32e832 --- /dev/null +++ b/apps/web/openapi/api-docs.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"FillMap API","description":"FillMap API 문서","version":"v1"},"servers":[{"url":"https://api.fillmap.kr","description":"Generated server url"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"격자 상세 (Grid Videos)","description":"격자를 탭했을 때 그 격자의 영상 조회 API — 내 영상 리스트·전역 대표 영상·전역 인기 목록."},{"name":"구역 (Zone)","description":"격자 표시명(\"서면 A-14\") 계산용 구역 데이터. FE 가 캐시해 로컬 명명·오버레이에 쓴다."},{"name":"격자 (Grid)","description":"개인 도감 색칠 격자 조회 API — 로그인 사용자가 점령한 격자만 반환한다."},{"name":"영상 (Video)","description":"영상 업로드·교체·삭제 API. 업로드는 presigned URL 발급 → S3 직접 업로드 → 메타데이터 저장 순서다."},{"name":"인증 (Auth)","description":"회원가입·로그인·소셜 로그인·토큰 재발급 API. 이 그룹의 엔드포인트는 인증 없이 호출한다."},{"name":"행정동 (Region)","description":"좌표를 포함하는 행정동을 우리 region_code 체계로 판정하는 역지오코딩 API."},{"name":"장소 검색 (Search)","description":"장소명 자유 텍스트 검색 — 카카오 로컬 키워드 검색 실시간 프록시 + 격자 ID 합성."},{"name":"전역 탐색 (Region Explore)","description":"행정동 축으로 전역 공개 콘텐츠를 탐색하는 API — 지도 홈 패널·전체 보기 격자 썸네일 뷰·검색 무입력 전체 지역 리스트."},{"name":"뱃지 (Badge)","description":"뱃지 API — 내 뱃지 목록 조회 · 대표 뱃지 집합 교체."},{"name":"미션 (Missions)","description":"지도 오버레이용 활성 미션 목록 조회 API."},{"name":"인증-개발용 (Auth Dev)","description":"로컬/dev 전용 — 소셜 로그인을 실제 소셜 토큰 없이 백엔드에서 테스트. 운영(prod) 미노출."},{"name":"도감 (Collection)","description":"개인 도감 요약 조회 API — 로그인 사용자의 점령·영상·방문 행정동 집계."},{"name":"핫구역 (HotZone)","description":"최근 48시간 방문(업로드) 신호 상위 격자 조회 API — 개인화 없는 공용 목록."}],"paths":{"/api/videos/{videoId}":{"get":{"tags":["영상 (Video)"],"summary":"단건 영상 재생 조회","description":"영상 하나의 표시용 메타와 재생본 presigned GET URL을 발급한다. 소유자·타인 모두 조회할 수 있으나 삭제·블라인드(타인)는 404, 비공개(타인)는 403이다. READY가 아니면 playbackUrl은 null이다.","operationId":"getPlayback","parameters":[{"name":"videoId","in":"path","description":"재생할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoPlaybackResponseDto"}}}}}},"put":{"tags":["영상 (Video)"],"summary":"영상 교체","description":"기존 영상을 새 파일로 교체한다. 좌표를 생략하면 격자를 유지하고 파일만 교체하며, 좌표를 보내면 기존과 같은 격자여야 한다(다르면 거부). 교체 직후 상태는 UPLOADED다.","operationId":"replace","parameters":[{"name":"videoId","in":"path","description":"교체할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoReplaceRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoReplaceResponseDto"}}}}}},"delete":{"tags":["영상 (Video)"],"summary":"영상 삭제","description":"영상을 삭제한다. 해당 격자의 내 영상이 모두 사라지면 점령이 롤백(색칠 해제)된다.","operationId":"delete","parameters":[{"name":"videoId","in":"path","description":"삭제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"responses":{"200":{"description":"OK"}}}},"/api/badges/featured":{"put":{"tags":["뱃지 (Badge)"],"summary":"대표 뱃지 집합 교체","description":"획득한 뱃지 중 최대 2개를 대표로 교체 지정한다(멱등). 배열 순서 = 표시 순서(rank 1·2), 빈 배열은 전부 해제. 미획득·미존재 뱃지는 7403, 중복 id 는 7400, 3개 이상은 400 이다.","operationId":"replaceFeatured","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeaturedBadgeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFeaturedBadgeResponseDto"}}}}}}},"/api/videos":{"post":{"tags":["영상 (Video)"],"summary":"영상 메타데이터 저장 (업로드 확정)","description":"S3 업로드 완료 후 영상 메타데이터를 저장하고 좌표로 격자를 매핑한다. 해당 격자에 내 첫 영상이면 점령(occupied=true)된다.","operationId":"upload","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoUploadResponseDto"}}}}}}},"/api/videos/presigned-url":{"post":{"tags":["영상 (Video)"],"summary":"업로드용 presigned URL 발급","description":"영상 파일을 S3에 직접 올릴 presigned URL을 발급한다. 이 URL로 PUT 업로드한 뒤 메타데이터 저장을 호출한다.","operationId":"issuePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUrlRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoPresignedUrlResponseDto"}}}}}}},"/api/auth/signup":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 회원가입","description":"이메일/비밀번호/닉네임으로 신규 회원을 생성한다.","operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoSignupResponseDto"}}}}}}},"/api/auth/reissue":{"post":{"tags":["인증 (Auth)"],"summary":"토큰 재발급","description":"리프레시 토큰(웹=쿠키, 앱=body)으로 새 액세스 토큰과 회전된 새 리프레시 토큰을 발급받는다. 직전 리프레시 토큰은 즉시 무효화되며, 회전된 옛 토큰 재사용 시 세션 체인이 폐기된다.","operationId":"reissue","parameters":[{"name":"refreshToken","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReissueRequestDto"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReissueResponseDto"}}}}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (OIDC)","description":"소셜 제공자의 ID Token으로 로그인/가입하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다.","operationId":"oauthLogin","parameters":[{"name":"provider","in":"path","description":"소셜 제공자","required":true,"schema":{"type":"string"},"example":"KAKAO"},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/logout":{"post":{"tags":["인증 (Auth)"],"summary":"로그아웃","description":"Authorization 헤더의 액세스 토큰을 무효화하고 해당 디바이스(X-Device-Id)의 리프레시 세션을 삭제한다. X-Device-Id 가 없으면 해당 유저의 모든 디바이스 세션을 삭제한다.","operationId":"logout","parameters":[{"name":"Authorization","in":"header","required":false,"schema":{"type":"string"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 모든 디바이스 세션 삭제(로그아웃-올).","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/auth/login":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 로그인","description":"이메일/비밀번호로 로그인하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키로, 앱(app)은 body 로 내려간다.","operationId":"login","parameters":[{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/dev/social-login":{"post":{"tags":["인증-개발용 (Auth Dev)"],"summary":"[개발용] 소셜 로그인 모의","description":"실제 OIDC ID Token 검증 없이 (provider, oid)로 사용자를 find-or-create 하고 액세스+리프레시 토큰을 발급한다. 리프레시는 body 로 내려간다(앱 모드). 로컬/dev 프로파일에서만 노출.","operationId":"socialLogin","parameters":[{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevSocialLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/videos/{videoId}/visibility":{"patch":{"tags":["영상 (Video)"],"summary":"영상 공개 범위 전환","description":"본인 영상의 공개 범위를 PUBLIC↔PRIVATE로 전환한다. 전환된 상태를 반환하며, 같은 값 재전환은 멱등하게 성공한다.","operationId":"setVisibility","parameters":[{"name":"videoId","in":"path","description":"공개 범위를 전환할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoVisibilityRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoVisibilityResponseDto"}}}}}}},"/api/zones":{"get":{"tags":["구역 (Zone)"],"summary":"구역 목록 조회","description":"전체 구역(zone) 목록을 반환한다. FE 가 캐시해 gridId 로 표시명을 로컬 산술하고 구역 오버레이에 쓴다. 시딩 전이면 빈 배열(전 시스템이 행정동 폴백으로 동작).","operationId":"getZones","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListZoneResponseDto"}}}}}}},"/api/search/places":{"get":{"tags":["장소 검색 (Search)"],"summary":"장소 검색 (장소명 → 좌표·격자)","description":"카카오 로컬 키워드 검색 결과(정확도순 ≤15건)에 각 좌표의 격자 ID 를 얹어 반환한다. 선택 즉시 lat/lng 지도 이동 + gridId 격자 하이라이트. q 누락 400 / trim 후 빈 q·무매치 200 [] / 카카오 장애·타임아웃 502(developCode 5502).","operationId":"searchPlaces","parameters":[{"name":"q","in":"query","description":"검색어 (자유 텍스트 장소명)","required":true,"schema":{"type":"string"},"example":"부산대"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListPlaceSearchResponseDto"}}}}}}},"/api/regions/{regionCode}/grids":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"행정동 격자 카드 리스트 + 헤더 카운트 조회","description":"그 행정동 격자들 중 전역 공개 콘텐츠(공개·인코딩 완료·타인 영상 포함)가 있는 격자를 카드로 반환한다. 헤더 카운트(gridCount·videoCount)는 limit 무관 전체 기준이라 지도 홈 패널(limit=3)과 전체 보기(limit 생략)가 같은 숫자를 본다. 카드 커버는 격자 대표(cover)와 같은 영상이고 썸네일은 presigned GET URL 이다. 미존재·무콘텐츠 regionCode 는 404 가 아니라 200 + 카운트 0·빈 배열이다.","operationId":"getRegionGrids","parameters":[{"name":"regionCode","in":"path","description":"행정동 코드 — reverse-geocode·전체 지역 리스트의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":2644056000},{"name":"sort","in":"query","description":"정렬 — POPULAR(조회수 합)·LATEST(최신 공개 영상). 대문자 전용이며 소문자 포함 무효 값은 400 이다","required":false,"schema":{"type":"string","default":"POPULAR","enum":["POPULAR","LATEST"]},"example":"POPULAR"},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 3. 생략하면 전부, 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":3}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExploreResponseDto"}}}}}}},"/api/regions/stats":{"get":{"tags":["행정동 (Region)"],"summary":"내 행정동별 수집률 조회","description":"로그인 사용자가 점령(수집)한 격자를 행정동별로 집계한 수집률 리스트를 반환한다. parentCode 로 시군구를 좁힐 수 있고(실존하지 않는 코드면 404/6404), collectedOnly=false 면 롤백으로 0이 된 행정동도 포함한다. 수집이 없으면 404 가 아니라 200 + 빈 배열.","operationId":"getStats","parameters":[{"name":"parentCode","in":"query","description":"상위 시군구 코드. 생략하면 전국. 실존하지 않으면 6404","required":false,"schema":{"type":"string"},"example":11680},{"name":"collectedOnly","in":"query","description":"true=수집한 행정동만, false=손댄 행정동 전부(롤백 0-row 포함)","required":false,"schema":{"type":"boolean","default":true},"example":true}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionStatResponseDto"}}}}}}},"/api/regions/stats/by-point":{"get":{"tags":["행정동 (Region)"],"summary":"현재 위치 행정동 탐험률 (좌표 → 수집률)","description":"도감 갤러리 진입 초기값. 현재 위치 좌표가 속한 행정동 1건의 내 수집률을 반환한다. 그 행정동에 수집이 없어도 0% 로 합성해 반환하고, 어떤 행정동에도 안 속하면(바다·국외) 404 가 아니라 200 + body null. 서비스 범위 밖 좌표는 400(6400).","operationId":"getStatByPoint","parameters":[{"name":"lat","in":"query","description":"위도","required":false,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lon","in":"query","description":"경도","required":false,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/stats/by-grid":{"get":{"tags":["행정동 (Region)"],"summary":"격자 중심 행정동 탐험률 (격자 클릭 → 수집률)","description":"클릭한 격자의 중심점이 속한 행정동 1건의 내 수집률을 반환한다. 귀속 축이 수집률 집계(MSG-155)와 같아 탐험률·라벨이 일치한다. 중심점이 어떤 행정동에도 안 속하거나 gridId 형식이 이상하면 200 + body null(별도 에러 코드 없음).","operationId":"getStatByGrid","parameters":[{"name":"gridId","in":"query","description":"격자 ID \"{grid_y}_{grid_x}\"","required":false,"schema":{"type":"string"},"example":"41642_110458"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/reverse-geocode":{"get":{"tags":["행정동 (Region)"],"summary":"역지오코딩 (좌표 → 행정동)","description":"좌표를 포함하는 행정동 1건을 반환한다. 포함 행정동이 없으면(바다·국외) 404가 아니라 200 + body null. 서비스 좌표 범위(한국) 밖이면 400(6400).","operationId":"reverseGeocode","parameters":[{"name":"lat","in":"query","description":"위도","required":false,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lon","in":"query","description":"경도","required":false,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionResponseDto"}}}}}}},"/api/regions/explore":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"전체 지역 리스트 조회","description":"전역 공개 콘텐츠가 있는 행정동만 격자 수 내림차순으로 반환한다(검색 무입력 드롭다운). 각 항목의 gridCount 는 그 행정동 격자 카드 조회의 gridCount 와 같은 정의다. 전역 공개 콘텐츠가 하나도 없으면 빈 배열이다.","operationId":"getExploreRegions","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionGridCountResponseDto"}}}}}}},"/api/missions/active":{"get":{"tags":["미션 (Missions)"],"summary":"활성 미션 목록 조회","description":"지금 활성인 미션 전부를 유형별 렌더 shape(코스=PATH·구역=REGION·축제=BOX·테마/지속=CELLS)로 반환한다. bbox 없이 전역 목록이며 1h 전역 캐시로 재계산을 흡수한다. 시드 전이거나 활성 미션이 없으면 빈 배열이다(404 아님).","operationId":"getActiveMissions","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionResponseDto"}}}}}}},"/api/hotzones":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의 핫구역을 핫스코어 내림차순으로 반환한다. 전국 상위 K(50)·최소 임계(3) 판정 후 뷰포트 필터 — 없으면 빈 목록이다.","operationId":"getHotZones","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHotZoneListResponseDto"}}}}}}},"/api/grids":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 조회 (커서 페이지네이션)","description":"지도 화면 bbox(남서~북동 좌표) 안에서 내가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답의 nextCursor를 다음 요청 cursor에 넣어 이어서 조회한다. bbox 한 변의 span은 최대 0.5도.","operationId":"getOccupiedInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":false,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":false,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":false,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":false,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"NDE2NDNfMTEwNDYw"},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/grids/{gridId}":{"get":{"tags":["격자 (Grid)"],"summary":"단일 격자 색칠 상태 조회","description":"특정 격자를 내가 점령(색칠)했는지와 내 영상 수를 반환한다. 미점령 격자도 404가 아니라 occupied=false로 응답한다.","operationId":"getCell","parameters":[{"name":"gridId","in":"path","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","required":true,"schema":{"type":"string"},"example":"41642_110458"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCellResponseDto"}}}}}}},"/api/grids/{gridId}/videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 영상 목록 조회","description":"그 격자에 쌓인 공개(PUBLIC)·READY 영상을 전역(본인·타인 포함)에서 조회수(viewCount) → 최신(createdAt) 순으로 페이지 조회한다. 비공개·삭제·인코딩 미완 영상은 본인 것이라도 제외한다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 무효 커서는 400(INVALID_CURSOR)이고, size 는 1~50 밖이면 클램프된다. 후보가 없거나 존재하지 않는 gridId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridGlobalVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"41642_110458"},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}}},"/api/grids/{gridId}/my-videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자별 내 영상 리스트 조회","description":"로그인 사용자가 해당 격자에 올린 본인 영상을 최근 업로드 순(createdAt DESC)으로 반환한다. 미점령·타인만 점령한 격자·존재하지 않는 gridId 는 빈 배열이다. 썸네일은 presigned GET URL 로 내려주며 READY 이전이면 null 이다.","operationId":"getGridVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"41642_110458"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridVideoResponseDto"}}}}}}},"/api/grids/{gridId}/cover":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 대표 영상 조회","description":"그 격자를 전역에서 대표하는 영상 1건을 반환한다. 공개(PUBLIC)·READY 영상 중 조회수(view_count) → 최신(createdAt) 순으로 뽑으며, 본인·타인 영상 모두 후보다. 비공개·삭제·인코딩 미완 영상은 제외한다. 후보가 없으면(미점령·비공개만·존재하지 않는 gridId) body 는 null 이다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridCover","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"41642_110458"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCoverVideoResponseDto"}}}}}}},"/api/collections/videos":{"get":{"tags":["도감 (Collection)"],"summary":"동 단위 내 영상 조회","description":"행정동(regionCode) 격자들에 올린 로그인 사용자의 영상을 created_at 내림차순으로 반환한다(무커서). regionCode 는 by-grid 응답의 regionCode 를 그대로 넘긴다. 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 포함된다. 내 도감이라 PRIVATE·인코딩 중 영상도 포함하며(status ACTIVE 만), 그 행정동에 내 영상이 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getRegionVideos","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — by-grid 응답의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":1168051500}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionVideoResponseDto"}}}}}}},"/api/collections/summary":{"get":{"tags":["도감 (Collection)"],"summary":"개인 도감 요약 조회","description":"로그인 사용자의 점령한 격자 수·올린 영상 총합·방문한 행정동 수를 한 번에 반환한다. 점령 0건 사용자도 에러 없이 세 값이 모두 0으로 응답한다.","operationId":"getSummary","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoCollectionSummaryResponseDto"}}}}}}},"/api/collections/grids":{"get":{"tags":["도감 (Collection)"],"summary":"갤러리 격자 목록 조회","description":"로그인 사용자가 최근 수집한 격자를 first_collected_at 내림차순 최대 30개로 반환한다(무커서). 각 항목은 gridId·gridY/gridX·수집/방문 시각·영상 수·cover 영상 ID·cover 썸네일 URL 을 담는다. 점령 0건 사용자는 에러 없이 빈 배열을 받는다.","operationId":"getCollectionGrids","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListCollectionGridResponseDto"}}}}}}},"/api/badges":{"get":{"tags":["뱃지 (Badge)"],"summary":"내 뱃지 전체 목록","description":"시딩된 전체 뱃지를 내 획득 상태와 함께 시딩 순(badges.id 오름차순)으로 반환한다 — 미획득 행은 earned false·earnedAt null·isNew false·featuredRank null. 이번 응답에 노출된 미확인(새 뱃지) 행은 자동으로 확인 처리되어 다음 조회부터 isNew false 가 된다.","operationId":"findMyBadges","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMyBadgeResponseDto"}}}}}}}},"components":{"schemas":{"VideoReplaceRequestDto":{"type":"object","description":"영상 교체 요청. 파일만 바꾸려면 좌표를 생략한다. 좌표를 보내면 기존과 같은 격자여야 하며 다르면 GRID_MISMATCH로 거부된다.","properties":{"s3Key":{"type":"string","description":"새로 업로드한 영상의 S3 객체 키","example":"videos/2026/07/new-uuid.mp4","minLength":1},"lat":{"type":["number","null"],"format":"double","description":"위도 (선택). lon과 함께 보내거나 둘 다 생략","example":37.5665},"lon":{"type":["number","null"],"format":"double","description":"경도 (선택). lat과 함께 보내거나 둘 다 생략","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoVideoReplaceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/VideoReplaceResponseDto"}}},"VideoReplaceResponseDto":{"type":"object","description":"영상 교체 응답. 교체 직후는 항상 재인코딩 대기(UPLOADED) 상태다.","properties":{"videoId":{"type":"integer","format":"int64","description":"교체된 영상 ID","example":1001},"processingStatus":{"type":"string","description":"영상 처리 상태 (교체 직후 UPLOADED)","example":"UPLOADED"}}},"FeaturedBadgeRequestDto":{"type":"object","description":"대표 뱃지 집합 교체 요청 — 배열 순서가 표시 순서, 빈 배열은 전부 해제","properties":{"badgeIds":{"type":"array","description":"대표로 지정할 뱃지 id 목록 (최대 2개, 순서 = 표시 순서)","example":[3,7],"items":{"type":"integer","format":"int64"},"maxItems":2,"minItems":0}},"required":["badgeIds"]},"ApiResponseDtoListFeaturedBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/FeaturedBadgeResponseDto"}}}},"FeaturedBadgeResponseDto":{"type":"object","description":"적용된 대표 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":3},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_50"},"name":{"type":"string","description":"표시명","example":"탐험가 II"},"iconUrl":{"type":"string","description":"아이콘 URL (에셋 확정 전 null)","example":"null"},"rank":{"type":"integer","format":"int32","description":"표시 순서 (1·2)","example":1}}},"VideoUploadRequestDto":{"type":"object","description":"S3 업로드 완료 후 영상 메타데이터 저장 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키","example":"videos/2026/07/uuid.mp4","minLength":1},"lat":{"type":"number","format":"double","description":"촬영 위치 위도 (격자 매핑에 사용)","example":37.5665},"lon":{"type":"number","format":"double","description":"촬영 위치 경도 (격자 매핑에 사용)","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00"},"visibility":{"type":"string","description":"공개범위. PUBLIC 또는 PRIVATE, 생략 시 PUBLIC","example":"PUBLIC"}},"required":["durationSec","lat","lon","recordedAt","s3Key"]},"ApiResponseDtoVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/VideoUploadResponseDto"}}},"CompletedMissionResponseDto":{"type":"object","description":"이번 업로드로 완료된 미션 스탬프","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID","example":3},"title":{"type":"string","description":"미션 제목","example":"성수 골목 코스"},"type":{"type":"string","description":"미션 유형 (COURSE/AREA/EVENT/THEME/CONTINUOUS)","example":"COURSE"}}},"EarnedBadgeResponseDto":{"type":"object","description":"이번 행동으로 새로 획득한 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":1},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_1"},"name":{"type":"string","description":"표시명","example":"첫 발자국"},"description":{"type":"string","description":"설명","example":"첫 격자를 수집했어요"},"iconUrl":{"type":"string","description":"아이콘 URL (에셋 확정 전 null)","example":"null"}}},"VideoUploadResponseDto":{"type":"object","description":"영상 메타데이터 저장 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"매핑된 격자 ID","example":"41642_110458"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 격자를 처음 점령(첫 방문)했는지 여부","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 완료된 미션 스탬프 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}}}},"PresignedUrlRequestDto":{"type":"object","description":"S3 업로드용 presigned URL 발급 요청","properties":{"extension":{"type":"string","description":"영상 파일 확장자 (점 없이)","example":"mp4","minLength":1},"contentType":{"type":"string","description":"영상 MIME 타입","example":"video/mp4","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 서버 상한 초과 시 거부","example":10485760}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoPresignedUrlResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/PresignedUrlResponseDto"}}},"PresignedUrlResponseDto":{"type":"object","description":"presigned URL 발급 응답. uploadUrl로 S3에 직접 PUT 업로드 후, s3Key로 메타데이터 저장(POST /api/videos)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/videos/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 이후 메타데이터 저장 요청에 그대로 전달한다.","example":"videos/2026/07/uuid.mp4"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":300}}},"SignupRequestDto":{"type":"object","description":"이메일 회원가입 요청","properties":{"email":{"type":"string","format":"email","description":"이메일 (최대 255자, 중복 불가)","example":"user@fillmap.dev","maxLength":255,"minLength":0},"password":{"type":"string","description":"비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"},"nickname":{"type":"string","description":"닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["email","nickname","password"]},"ApiResponseDtoSignupResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/SignupResponseDto"}}},"SignupResponseDto":{"type":"object","description":"회원가입 성공 응답 — 생성된 사용자 정보","properties":{"id":{"type":"integer","format":"int64","description":"생성된 사용자 ID","example":1},"email":{"type":"string","description":"가입 이메일","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임","example":"채우미"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각","example":"2026-07-17T20:11:03"}}},"ReissueRequestDto":{"type":"object","description":"토큰 재발급 요청. 웹은 리프레시 토큰이 쿠키(refreshToken)로 전송되므로 body 를 생략할 수 있다.","properties":{"refreshToken":{"type":"string","description":"앱(X-Client-Type: app) 클라이언트의 리프레시 토큰. 웹은 쿠키를 사용하므로 생략.","example":"eyJhbGciOiJIUzI1NiJ9..."}}},"ApiResponseDtoReissueResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/ReissueResponseDto"}}},"ReissueResponseDto":{"type":"object","description":"토큰 재발급 성공 응답","properties":{"accessToken":{"type":"string","description":"새로 발급된 JWT 액세스 토큰.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":"string","description":"회전된 새 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 재설정되므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."}}},"OidcLoginRequestDto":{"type":"object","description":"소셜(OIDC) 로그인 요청","properties":{"idToken":{"type":"string","description":"소셜 제공자(카카오 등)에서 발급받은 OIDC ID Token","example":"eyJraWQiOiI...","minLength":1}},"required":["idToken"]},"ApiResponseDtoLoginResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/LoginResponseDto"}}},"LoginResponseDto":{"type":"object","description":"로그인 성공 응답","properties":{"accessToken":{"type":"string","description":"발급된 JWT 액세스 토큰. 이후 요청 Authorization 헤더에 'Bearer {토큰}'으로 넣는다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":"string","description":"발급된 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 내려가므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."}}},"LoginRequestDto":{"type":"object","description":"이메일/비밀번호 로그인 요청","properties":{"email":{"type":"string","format":"email","description":"가입한 이메일","example":"user@fillmap.dev","minLength":1},"password":{"type":"string","description":"비밀번호 (영문+숫자 포함 8~64자)","example":"Fillmap1234","minLength":1}},"required":["email","password"]},"DevSocialLoginRequestDto":{"type":"object","description":"[로컬/dev 전용] 소셜 로그인 모의 요청 — 실제 소셜 ID Token 없이 (provider, oid)로 로그인/가입한다.","properties":{"provider":{"type":"string","description":"소셜 제공자 (기본 KAKAO)","example":"KAKAO"},"oid":{"type":"string","description":"소셜 고유 식별자(oid). 같은 값이면 같은 사용자로 재로그인된다.","example":"dev-kakao-1","minLength":1},"email":{"type":"string","description":"이메일 (선택). 없으면 {oid}@dev.local","example":"kakaouser@dev.local"},"nickname":{"type":"string","description":"닉네임 (선택). 없으면 dev-{oid}","example":"카카오테스터"}},"required":["oid"]},"VideoVisibilityRequestDto":{"type":"object","description":"영상 공개 범위 전환 요청. PUBLIC 또는 PRIVATE.","properties":{"visibility":{"type":"string","description":"공개 범위. PUBLIC 또는 PRIVATE (대소문자 무관)","example":"PUBLIC","minLength":1}},"required":["visibility"]},"ApiResponseDtoVideoVisibilityResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/VideoVisibilityResponseDto"}}},"VideoVisibilityResponseDto":{"type":"object","description":"영상 공개 범위 전환 응답. 전환 후 공개 범위를 담는다.","properties":{"videoId":{"type":"integer","format":"int64","description":"전환된 영상 ID","example":1042},"visibility":{"type":"string","description":"전환 후 공개 범위 (PUBLIC 또는 PRIVATE)","example":"PUBLIC"}}},"ApiResponseDtoListZoneResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/ZoneResponseDto"}}}},"ZoneResponseDto":{"type":"object","description":"격자 표시명 계산용 구역(zone). 정수 사각형 + 이름 + 소속 행정동.","properties":{"zoneKey":{"type":"string","description":"안정 식별자 slug (zones.zone_key) — 클라이언트 참조·타이브레이크 기준","example":"seomyeon"},"name":{"type":"string","description":"구역명 (zones.name)","example":"서면"},"regionCode":{"type":"string","description":"소속 행정동 코드 (zones.region_code, nullable)","example":"2623051000"},"minGridY":{"type":"integer","format":"int32","description":"사각형 남단 행 (zones.min_grid_y)","example":39710},"maxGridY":{"type":"integer","format":"int32","description":"사각형 북단 행 = A행 (zones.max_grid_y)","example":39725},"minGridX":{"type":"integer","format":"int32","description":"사각형 서단 열 = 1열 (zones.min_grid_x)","example":109830},"maxGridX":{"type":"integer","format":"int32","description":"사각형 동단 열 (zones.max_grid_x)","example":109850},"priority":{"type":"integer","format":"int32","description":"겹침 결정성 우선순위 (zones.priority)","example":0}}},"ApiResponseDtoVideoPlaybackResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/VideoPlaybackResponseDto"}}},"VideoPlaybackResponseDto":{"type":"object","description":"단건 영상 재생 조회 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID","example":1042},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL. READY 아님·BLINDED(소유자)면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 없음(READY 이전)이면 null"},"gridId":{"type":"string","description":"이 영상이 속한 격자 ID","example":"41642_110458"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"visibility":{"type":"string","description":"공개 범위 (PUBLIC/PRIVATE)","example":"PUBLIC"},"status":{"type":"string","description":"영상 상태 (ACTIVE/BLINDED). 소유자가 블라인드 사유를 구분하는 축","example":"ACTIVE"},"viewCount":{"type":"integer","format":"int64","description":"조회수 (이번 조회 증가 전 스냅샷)","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초). playbackUrl=null 이면 null"}}},"ApiResponseDtoListPlaceSearchResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/PlaceSearchResponseDto"}}}},"PlaceSearchResponseDto":{"type":"object","description":"장소 검색 결과 1건. 선택 시 lat/lng 로 지도 이동 + gridId 로 격자 하이라이트를 한 번에 처리한다.","properties":{"name":{"type":"string","description":"장소명 (카카오 place_name)","example":"부산대학교"},"address":{"type":"string","description":"표시용 주소 — 도로명 우선, 없으면 지번 (§D2)","example":"부산 금정구 부산대학로63번길 2"},"lat":{"type":"number","format":"double","description":"위도 (WGS84, 카카오 y 직결 — 변환 없음)","example":35.23272},"lng":{"type":"number","format":"double","description":"경도 (WGS84, 카카오 x)","example":129.08246},"gridId":{"type":"string","description":"그 좌표의 격자 ID — FE 격자 하이라이트 키 (즉석 계산, 저장 아님)","example":"39147_112245"}}},"ApiResponseDtoRegionExploreResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/RegionExploreResponseDto"}}},"ExploreGridResponseDto":{"type":"object","description":"전역 탐색 격자 카드","properties":{"gridId":{"type":"string","description":"격자 ID — 카드 탭 시 격자 전역 영상 목록(MSG-237) 진입 키","example":"38879_112390"},"gridY":{"type":"integer","format":"int64","description":"격자 위도 인덱스 (FE 지도 이동·라벨 조합)","example":38879},"gridX":{"type":"integer","format":"int64","description":"격자 경도 인덱스","example":112390},"videoCount":{"type":"integer","format":"int32","description":"그 격자의 게이트 통과 영상 수 — \"N개 영상\"","example":138},"coverThumbnailUrl":{"type":"string","description":"커버 썸네일 presigned GET URL. READY 게이트라 non-null 기대(null 이면 null 통과)"},"coverDurationSec":{"type":"integer","format":"int32","description":"커버 영상 길이(초) — duration 뱃지","example":12}}},"RegionExploreResponseDto":{"type":"object","description":"행정동 격자 카드 리스트 + 헤더 카운트","properties":{"regionCode":{"type":"string","description":"행정동 코드 (요청 에코)","example":"2644056000"},"regionName":{"type":["string","null"],"description":"행정동 이름 — 미존재 코드면 null","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"게이트 통과 영상 ≥1 격자 수 — \"이 지역 격자 N개\"","example":5},"videoCount":{"type":"integer","format":"int64","description":"게이트 통과 영상 총수 — \"영상 M개\"","example":355},"grids":{"type":"array","description":"격자 카드 (정렬·limit 적용 후). 없으면 빈 배열","items":{"$ref":"#/components/schemas/ExploreGridResponseDto"}}}},"ApiResponseDtoListRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/RegionStatResponseDto"}}}},"RegionStatResponseDto":{"type":"object","description":"한 행정동의 수집률. 사용자가 그 행정동에서 점령(수집)한 격자 수와 진행률.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (region_stats.region_code)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":"string","description":"상위 시군구 코드 (regions.parent_code)","example":"11680"},"collectedCount":{"type":"integer","format":"int32","description":"점령(수집)한 격자 수","example":5},"totalCount":{"type":"integer","format":"int32","description":"그 행정동 전체 격자 수(분모)","example":20},"progressRate":{"type":"number","description":"수집률(%) — 100 상한 clamp","example":25.0},"updatedAt":{"type":"string","format":"date-time","description":"수집률 캐시 기준 시각","example":"2026-07-20T10:00:00"}}},"ApiResponseDtoRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/RegionStatResponseDto"}}},"ApiResponseDtoRegionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/RegionResponseDto"}}},"RegionResponseDto":{"type":"object","description":"좌표를 포함하는 행정동. 포함 행정동이 없으면(바다·국외) body 가 null 이다.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (regions.region_code = adm_cd2)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name = adm_nm)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":"string","description":"상위 시군구 코드 (regions.parent_code)","example":"11680"}}},"ApiResponseDtoListRegionGridCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/RegionGridCountResponseDto"}}}},"RegionGridCountResponseDto":{"type":"object","description":"전체 지역 리스트 항목 (행정동별 격자 수)","properties":{"regionCode":{"type":"string","description":"행정동 코드 — 선택 시 격자 카드 조회에 전달","example":"2644056000"},"regionName":{"type":"string","description":"행정동 이름","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"그 행정동의 게이트 통과 격자 수","example":5}}},"ApiResponseDtoListMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/MissionResponseDto"}}}},"BoxShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"polygon":{"type":"array","items":{"$ref":"#/components/schemas/LatLng"}}}}],"description":"이벤트(EVENT) — 격자 집합을 감싸는 경계 사각형"},"Cell":{"type":"object","description":"격자 중심점","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lon":{"type":"number","format":"double"}}},"CellsShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"cells":{"type":"array","items":{"$ref":"#/components/schemas/Cell"}}}}],"description":"테마·지속(THEME·CONTINUOUS) — 각 격자 중심점"},"LatLng":{"type":"object","description":"좌표 한 점","properties":{"lat":{"type":"number","format":"double"},"lon":{"type":"number","format":"double"}}},"MissionResponseDto":{"type":"object","description":"활성 미션 하나 — 공통 필드 + 유형별 렌더 shape","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":12},"type":{"type":"string","description":"미션 유형 — FE 렌더러 판별자","enum":["COURSE","AREA","EVENT","THEME","CONTINUOUS","POPUP"],"example":"COURSE"},"title":{"type":"string","description":"미션 제목","example":"남파랑길 3코스"},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 distinct 방문 격자 수(표시·판정 힌트, 판정은 MSG-223)","example":3},"startAt":{"type":"string","format":"date-time","description":"시작 시각. NULL = 무기간(상시)","example":"2026-11-01T00:00:00"},"endAt":{"type":"string","format":"date-time","description":"종료 시각. NULL = 무기간(상시)","example":"2026-11-01T23:59:59"},"shape":{"description":"유형별 렌더 shape 하나(type 에 대응하는 PATH/BOX/CELLS/REGION)","oneOf":[{"$ref":"#/components/schemas/BoxShape"},{"$ref":"#/components/schemas/CellsShape"},{"$ref":"#/components/schemas/PathShape"},{"$ref":"#/components/schemas/RegionShape"}]}}},"MissionShape":{"description":"미션 유형별 렌더 shape (상위 type 으로 판별). PATH·BOX·CELLS·REGION 중 하나."},"PathShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"line":{"type":"string","description":"코스 라인 GeoJSON LineString 원문"},"spots":{"type":"array","items":{"$ref":"#/components/schemas/Spot"}}}}],"description":"코스(COURSE) — GeoJSON LineString + seq순 포토스팟 마커"},"RegionShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"regionCode":{"type":"string"}}}],"description":"구역(AREA) — region_code 만(경계는 region API 로 별도 조회)"},"Spot":{"type":"object","description":"코스 포토스팟 마커","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lon":{"type":"number","format":"double"},"seq":{"type":"integer","format":"int32"}}},"ApiResponseDtoHotZoneListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/HotZoneListResponseDto"}}},"HotZoneListResponseDto":{"type":"object","description":"뷰포트 내 핫구역 목록 응답 (핫스코어 내림차순)","properties":{"hotZones":{"type":"array","description":"핫구역 목록 — 핫스코어 내림차순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/HotZoneResponseDto"}}}},"HotZoneResponseDto":{"type":"object","description":"핫구역 한 칸 — 최근 48시간 방문(업로드) 신호가 상위인 격자","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"41642_110458"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (위도 기반 정수)","example":41642},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (경도 기반 정수)","example":110458},"score":{"type":"integer","format":"int64","description":"핫스코어 — 최근 48시간(8버킷) 방문 신호 합산","example":12}}},"ApiResponseDtoOccupiedGridPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/OccupiedGridPageResponseDto"}}},"OccupiedGridPageResponseDto":{"type":"object","description":"뷰포트 색칠 격자 페이지 응답 (커서 페이지네이션)","properties":{"grids":{"type":"array","description":"이 페이지의 색칠 격자 목록 ((grid_y, grid_x) 오름차순)","items":{"$ref":"#/components/schemas/OccupiedGridResponseDto"}},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null.","example":"NDE2NDNfMTEwNDYw"}}},"OccupiedGridResponseDto":{"type":"object","description":"뷰포트 색칠 격자 한 칸 — 지도 렌더링용 위치 정보","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"41642_110458"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (위도 기반 정수)","example":41642},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (경도 기반 정수)","example":110458}}},"ApiResponseDtoGridCellResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/GridCellResponseDto"}}},"GridCellResponseDto":{"type":"object","description":"단일 격자의 내 색칠(점령) 상태. 미점령이어도 404가 아니라 occupied=false로 응답한다.","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"41642_110458"},"occupied":{"type":"boolean","description":"내가 이 격자를 점령(색칠)했는지 여부","example":true},"videoCount":{"type":"integer","format":"int32","description":"이 격자에 올린 내 영상 수 (미점령이면 0)","example":3}}},"ApiResponseDtoGridVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/GridVideoPageResponseDto"}}},"GridGlobalVideoResponseDto":{"type":"object","description":"격자 전역 영상 목록 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID. 항목 탭 → 단건 재생(GET /api/videos/{videoId}) 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 목록은 READY 만 담겨 null 아님이 기대값이다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수 — 인기순 정렬 근거","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다","example":"2026-07-20T18:03:11"}}},"GridVideoPageResponseDto":{"type":"object","description":"격자 전역 영상 목록 페이지 응답 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 전역 공개·READY 영상 (인기순). 없으면 빈 배열","items":{"$ref":"#/components/schemas/GridGlobalVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null.","example":"NDE2NDJfMTEwNDU4OjU6MTc4NDQ1NTgwMDAwMDAwMDoxMDM5"}}},"ApiResponseDtoListGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/GridVideoResponseDto"}}}},"GridVideoResponseDto":{"type":"object","description":"격자별 내 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11"}}},"ApiResponseDtoGridCoverVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/GridCoverVideoResponseDto"}}},"GridCoverVideoResponseDto":{"type":"object","description":"격자 전역 대표 영상","properties":{"videoId":{"type":"integer","format":"int64","description":"대표 영상 ID. 개별 재생 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 대표는 항상 READY 라 null 이 아니다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수 — 대표 선정 정렬 키","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다","example":"2026-07-20T18:03:11"}}},"ApiResponseDtoListRegionVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/RegionVideoResponseDto"}}}},"RegionVideoResponseDto":{"type":"object","description":"동 단위 내 영상 리스트 항목 — 그 행정동 격자들에 올린 내 영상 하나.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"gridId":{"type":"string","description":"영상이 속한 격자 ID \"{grid_y}_{grid_x}\" — 항목별 격자 라벨·지도 이동용","example":"41642_110458"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11"}}},"ApiResponseDtoCollectionSummaryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"$ref":"#/components/schemas/CollectionSummaryResponseDto"}}},"CollectionSummaryResponseDto":{"type":"object","description":"개인 도감 요약 — 점령한 격자 수·올린 영상 총합·방문한 행정동 수.","properties":{"totalGridCount":{"type":"integer","format":"int32","description":"내가 점령한 격자 수 (도감 크기)","example":15},"totalVideoCount":{"type":"integer","format":"int64","description":"내가 올린 영상 총합 (활성 영상만)","example":42},"visitedRegionCount":{"type":"integer","format":"int32","description":"내가 방문한 서로 다른 행정동 수","example":6}}},"ApiResponseDtoListCollectionGridResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/CollectionGridResponseDto"}}}},"CollectionGridResponseDto":{"type":"object","description":"갤러리 격자 항목 — 내가 수집한 격자 하나와 cover 썸네일.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"41642_110458"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":41642},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":110458},"firstCollectedAt":{"type":"string","format":"date-time","description":"최초 수집(점령) 시각 — 정렬 키","example":"2026-07-20T18:03:11"},"lastUploadedAt":{"type":"string","format":"date-time","description":"마지막 방문(업로드) 시각","example":"2026-07-21T09:12:00"},"videoCount":{"type":"integer","format":"int32","description":"그 격자 내 내 영상 수","example":3},"coverVideoId":{"type":["integer","null"],"format":"int64","description":"cover 영상 ID(없으면 null)","example":1042},"coverThumbnailUrl":{"type":["string","null"],"description":"cover 썸네일 presigned GET URL(없거나 READY 이전이면 null)"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"}}},"ApiResponseDtoListMyBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"body":{"type":"array","items":{"$ref":"#/components/schemas/MyBadgeResponseDto"}}}},"MyBadgeResponseDto":{"type":"object","description":"내 뱃지 목록 행 — 획득+미획득 전체","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":2},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_10"},"name":{"type":"string","description":"표시명","example":"탐험가 I"},"description":{"type":"string","description":"설명","example":"격자 10개를 수집했어요"},"iconUrl":{"type":"string","description":"아이콘 URL (에셋 확정 전 null)","example":"null"},"earned":{"type":"boolean","description":"획득 여부","example":true},"earnedAt":{"type":"string","format":"date-time","description":"획득 시각 — 미획득이면 null","example":"2026-07-29T11:02:31"},"isNew":{"type":"boolean","description":"미확인(새 뱃지) 여부 — 미획득이면 false","example":false},"featuredRank":{"type":"integer","format":"int32","description":"대표 뱃지 순서(1·2) — 대표 아니면 null","example":1}}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index d4e564df..edf52424 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "openapi-ts": "openapi-ts", "preview": "vite preview", "test": "vitest", "typecheck": "tsc -b" @@ -31,6 +32,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@fillmap/tailwind-preset": "workspace:*", + "@hey-api/openapi-ts": "^0.99.0", "@tailwindcss/vite": "^4.3.2", "@testing-library/react": "^16.3.2", "@types/navermaps": "^3.9.2", diff --git a/apps/web/src/shared/api/generated/index.ts b/apps/web/src/shared/api/generated/index.ts new file mode 100644 index 00000000..11596387 --- /dev/null +++ b/apps/web/src/shared/api/generated/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ApiResponseDtoCollectionSummaryResponseDto, ApiResponseDtoGridCellResponseDto, ApiResponseDtoGridCoverVideoResponseDto, ApiResponseDtoGridVideoPageResponseDto, ApiResponseDtoHotZoneListResponseDto, ApiResponseDtoListCollectionGridResponseDto, ApiResponseDtoListFeaturedBadgeResponseDto, ApiResponseDtoListGridVideoResponseDto, ApiResponseDtoListMissionResponseDto, ApiResponseDtoListMyBadgeResponseDto, ApiResponseDtoListPlaceSearchResponseDto, ApiResponseDtoListRegionGridCountResponseDto, ApiResponseDtoListRegionStatResponseDto, ApiResponseDtoListRegionVideoResponseDto, ApiResponseDtoListZoneResponseDto, ApiResponseDtoLoginResponseDto, ApiResponseDtoOccupiedGridPageResponseDto, ApiResponseDtoPresignedUrlResponseDto, ApiResponseDtoRegionExploreResponseDto, ApiResponseDtoRegionResponseDto, ApiResponseDtoRegionStatResponseDto, ApiResponseDtoReissueResponseDto, ApiResponseDtoSignupResponseDto, ApiResponseDtoVideoPlaybackResponseDto, ApiResponseDtoVideoReplaceResponseDto, ApiResponseDtoVideoUploadResponseDto, ApiResponseDtoVideoVisibilityResponseDto, BoxShape, Cell, CellsShape, ClientOptions, CollectionGridResponseDto, CollectionSummaryResponseDto, CompletedMissionResponseDto, DeleteData, DeleteResponses, DevSocialLoginRequestDto, EarnedBadgeResponseDto, ExploreGridResponseDto, FeaturedBadgeRequestDto, FeaturedBadgeResponseDto, FindMyBadgesData, FindMyBadgesResponse, FindMyBadgesResponses, GetActiveMissionsData, GetActiveMissionsResponse, GetActiveMissionsResponses, GetCellData, GetCellResponse, GetCellResponses, GetCollectionGridsData, GetCollectionGridsResponse, GetCollectionGridsResponses, GetExploreRegionsData, GetExploreRegionsResponse, GetExploreRegionsResponses, GetGridCoverData, GetGridCoverResponse, GetGridCoverResponses, GetGridGlobalVideosData, GetGridGlobalVideosResponse, GetGridGlobalVideosResponses, GetGridVideosData, GetGridVideosResponse, GetGridVideosResponses, GetHotZonesData, GetHotZonesResponse, GetHotZonesResponses, GetOccupiedInViewportData, GetOccupiedInViewportResponse, GetOccupiedInViewportResponses, GetPlaybackData, GetPlaybackResponse, GetPlaybackResponses, GetRegionGridsData, GetRegionGridsResponse, GetRegionGridsResponses, GetRegionVideosData, GetRegionVideosResponse, GetRegionVideosResponses, GetStatByGridData, GetStatByGridResponse, GetStatByGridResponses, GetStatByPointData, GetStatByPointResponse, GetStatByPointResponses, GetStatsData, GetStatsResponse, GetStatsResponses, GetSummaryData, GetSummaryResponse, GetSummaryResponses, GetZonesData, GetZonesResponse, GetZonesResponses, GridCellResponseDto, GridCoverVideoResponseDto, GridGlobalVideoResponseDto, GridVideoPageResponseDto, GridVideoResponseDto, HotZoneListResponseDto, HotZoneResponseDto, IssuePresignedUrlData, IssuePresignedUrlResponse, IssuePresignedUrlResponses, LatLng, LoginData, LoginRequestDto, LoginResponse, LoginResponseDto, LoginResponses, LogoutData, LogoutResponses, MissionResponseDto, MissionShape, MyBadgeResponseDto, OauthLoginData, OauthLoginResponse, OauthLoginResponses, OccupiedGridPageResponseDto, OccupiedGridResponseDto, OidcLoginRequestDto, PathShape, PlaceSearchResponseDto, PresignedUrlRequestDto, PresignedUrlResponseDto, RegionExploreResponseDto, RegionGridCountResponseDto, RegionResponseDto, RegionShape, RegionStatResponseDto, RegionVideoResponseDto, ReissueData, ReissueRequestDto, ReissueResponse, ReissueResponseDto, ReissueResponses, ReplaceData, ReplaceFeaturedData, ReplaceFeaturedResponse, ReplaceFeaturedResponses, ReplaceResponse, ReplaceResponses, ReverseGeocodeData, ReverseGeocodeResponse, ReverseGeocodeResponses, SearchPlacesData, SearchPlacesResponse, SearchPlacesResponses, SetVisibilityData, SetVisibilityResponse, SetVisibilityResponses, SignupData, SignupRequestDto, SignupResponse, SignupResponseDto, SignupResponses, SocialLoginData, SocialLoginResponse, SocialLoginResponses, Spot, UploadData, UploadResponse, UploadResponses, VideoPlaybackResponseDto, VideoReplaceRequestDto, VideoReplaceResponseDto, VideoUploadRequestDto, VideoUploadResponseDto, VideoVisibilityRequestDto, VideoVisibilityResponseDto, ZoneResponseDto } from './types.gen'; diff --git a/apps/web/src/shared/api/generated/types.gen.ts b/apps/web/src/shared/api/generated/types.gen.ts new file mode 100644 index 00000000..cf37d1a1 --- /dev/null +++ b/apps/web/src/shared/api/generated/types.gen.ts @@ -0,0 +1,1878 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.fillmap.kr' | (string & {}); +}; + +/** + * 영상 교체 요청. 파일만 바꾸려면 좌표를 생략한다. 좌표를 보내면 기존과 같은 격자여야 하며 다르면 GRID_MISMATCH로 거부된다. + */ +export type VideoReplaceRequestDto = { + /** + * 새로 업로드한 영상의 S3 객체 키 + */ + s3Key: string; + /** + * 위도 (선택). lon과 함께 보내거나 둘 다 생략 + */ + lat?: number | null; + /** + * 경도 (선택). lat과 함께 보내거나 둘 다 생략 + */ + lon?: number | null; + /** + * 영상 길이(초). 1~30초 + */ + durationSec: number; + /** + * 촬영 시각 + */ + recordedAt: string; +}; + +export type ApiResponseDtoVideoReplaceResponseDto = { + developCode?: number; + message?: string; + body?: VideoReplaceResponseDto; +}; + +/** + * 영상 교체 응답. 교체 직후는 항상 재인코딩 대기(UPLOADED) 상태다. + */ +export type VideoReplaceResponseDto = { + /** + * 교체된 영상 ID + */ + videoId?: number; + /** + * 영상 처리 상태 (교체 직후 UPLOADED) + */ + processingStatus?: string; +}; + +/** + * 대표 뱃지 집합 교체 요청 — 배열 순서가 표시 순서, 빈 배열은 전부 해제 + */ +export type FeaturedBadgeRequestDto = { + /** + * 대표로 지정할 뱃지 id 목록 (최대 2개, 순서 = 표시 순서) + */ + badgeIds: Array; +}; + +export type ApiResponseDtoListFeaturedBadgeResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 적용된 대표 뱃지 + */ +export type FeaturedBadgeResponseDto = { + /** + * 뱃지 ID + */ + badgeId?: number; + /** + * 뱃지 code + */ + code?: string; + /** + * 표시명 + */ + name?: string; + /** + * 아이콘 URL (에셋 확정 전 null) + */ + iconUrl?: string; + /** + * 표시 순서 (1·2) + */ + rank?: number; +}; + +/** + * S3 업로드 완료 후 영상 메타데이터 저장 요청 + */ +export type VideoUploadRequestDto = { + /** + * presigned 발급 때 받은 S3 객체 키 + */ + s3Key: string; + /** + * 촬영 위치 위도 (격자 매핑에 사용) + */ + lat: number; + /** + * 촬영 위치 경도 (격자 매핑에 사용) + */ + lon: number; + /** + * 영상 길이(초). 1~30초 + */ + durationSec: number; + /** + * 촬영 시각 + */ + recordedAt: string; + /** + * 공개범위. PUBLIC 또는 PRIVATE, 생략 시 PUBLIC + */ + visibility?: string; +}; + +export type ApiResponseDtoVideoUploadResponseDto = { + developCode?: number; + message?: string; + body?: VideoUploadResponseDto; +}; + +/** + * 이번 업로드로 완료된 미션 스탬프 + */ +export type CompletedMissionResponseDto = { + /** + * 미션 ID + */ + missionId?: number; + /** + * 미션 제목 + */ + title?: string; + /** + * 미션 유형 (COURSE/AREA/EVENT/THEME/CONTINUOUS) + */ + type?: string; +}; + +/** + * 이번 행동으로 새로 획득한 뱃지 + */ +export type EarnedBadgeResponseDto = { + /** + * 뱃지 ID + */ + badgeId?: number; + /** + * 뱃지 code + */ + code?: string; + /** + * 표시명 + */ + name?: string; + /** + * 설명 + */ + description?: string; + /** + * 아이콘 URL (에셋 확정 전 null) + */ + iconUrl?: string; +}; + +/** + * 영상 메타데이터 저장 응답 + */ +export type VideoUploadResponseDto = { + /** + * 생성된 영상 ID + */ + videoId?: number; + /** + * 매핑된 격자 ID + */ + gridId?: string; + /** + * 영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED) + */ + processingStatus?: string; + /** + * 이 업로드로 격자를 처음 점령(첫 방문)했는지 여부 + */ + occupied?: boolean; + /** + * 이 업로드로 새로 획득한 뱃지 목록 — 없으면 빈 배열 + */ + newBadges?: Array; + /** + * 이 업로드로 완료된 미션 스탬프 목록 — 없으면 빈 배열 + */ + completedMissions?: Array; +}; + +/** + * S3 업로드용 presigned URL 발급 요청 + */ +export type PresignedUrlRequestDto = { + /** + * 영상 파일 확장자 (점 없이) + */ + extension: string; + /** + * 영상 MIME 타입 + */ + contentType: string; + /** + * 업로드할 파일 크기(바이트). 서버 상한 초과 시 거부 + */ + contentLength: number; +}; + +export type ApiResponseDtoPresignedUrlResponseDto = { + developCode?: number; + message?: string; + body?: PresignedUrlResponseDto; +}; + +/** + * presigned URL 발급 응답. uploadUrl로 S3에 직접 PUT 업로드 후, s3Key로 메타데이터 저장(POST /api/videos)을 호출한다. + */ +export type PresignedUrlResponseDto = { + /** + * S3에 직접 PUT 업로드할 presigned URL + */ + uploadUrl?: string; + /** + * 업로드 대상 S3 객체 키. 이후 메타데이터 저장 요청에 그대로 전달한다. + */ + s3Key?: string; + /** + * presigned URL 유효 시간(초) + */ + expiresInSec?: number; +}; + +/** + * 이메일 회원가입 요청 + */ +export type SignupRequestDto = { + /** + * 이메일 (최대 255자, 중복 불가) + */ + email: string; + /** + * 비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자 + */ + password: string; + /** + * 닉네임 (2~20자) + */ + nickname: string; +}; + +export type ApiResponseDtoSignupResponseDto = { + developCode?: number; + message?: string; + body?: SignupResponseDto; +}; + +/** + * 회원가입 성공 응답 — 생성된 사용자 정보 + */ +export type SignupResponseDto = { + /** + * 생성된 사용자 ID + */ + id?: number; + /** + * 가입 이메일 + */ + email?: string; + /** + * 닉네임 + */ + nickname?: string; + /** + * 가입 시각 + */ + createdAt?: string; +}; + +/** + * 토큰 재발급 요청. 웹은 리프레시 토큰이 쿠키(refreshToken)로 전송되므로 body 를 생략할 수 있다. + */ +export type ReissueRequestDto = { + /** + * 앱(X-Client-Type: app) 클라이언트의 리프레시 토큰. 웹은 쿠키를 사용하므로 생략. + */ + refreshToken?: string; +}; + +export type ApiResponseDtoReissueResponseDto = { + developCode?: number; + message?: string; + body?: ReissueResponseDto; +}; + +/** + * 토큰 재발급 성공 응답 + */ +export type ReissueResponseDto = { + /** + * 새로 발급된 JWT 액세스 토큰. + */ + accessToken?: string; + /** + * 회전된 새 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 재설정되므로 null 이다. + */ + refreshToken?: string; +}; + +/** + * 소셜(OIDC) 로그인 요청 + */ +export type OidcLoginRequestDto = { + /** + * 소셜 제공자(카카오 등)에서 발급받은 OIDC ID Token + */ + idToken: string; +}; + +export type ApiResponseDtoLoginResponseDto = { + developCode?: number; + message?: string; + body?: LoginResponseDto; +}; + +/** + * 로그인 성공 응답 + */ +export type LoginResponseDto = { + /** + * 발급된 JWT 액세스 토큰. 이후 요청 Authorization 헤더에 'Bearer {토큰}'으로 넣는다. + */ + accessToken?: string; + /** + * 발급된 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 내려가므로 null 이다. + */ + refreshToken?: string; +}; + +/** + * 이메일/비밀번호 로그인 요청 + */ +export type LoginRequestDto = { + /** + * 가입한 이메일 + */ + email: string; + /** + * 비밀번호 (영문+숫자 포함 8~64자) + */ + password: string; +}; + +/** + * [로컬/dev 전용] 소셜 로그인 모의 요청 — 실제 소셜 ID Token 없이 (provider, oid)로 로그인/가입한다. + */ +export type DevSocialLoginRequestDto = { + /** + * 소셜 제공자 (기본 KAKAO) + */ + provider?: string; + /** + * 소셜 고유 식별자(oid). 같은 값이면 같은 사용자로 재로그인된다. + */ + oid: string; + /** + * 이메일 (선택). 없으면 {oid}@dev.local + */ + email?: string; + /** + * 닉네임 (선택). 없으면 dev-{oid} + */ + nickname?: string; +}; + +/** + * 영상 공개 범위 전환 요청. PUBLIC 또는 PRIVATE. + */ +export type VideoVisibilityRequestDto = { + /** + * 공개 범위. PUBLIC 또는 PRIVATE (대소문자 무관) + */ + visibility: string; +}; + +export type ApiResponseDtoVideoVisibilityResponseDto = { + developCode?: number; + message?: string; + body?: VideoVisibilityResponseDto; +}; + +/** + * 영상 공개 범위 전환 응답. 전환 후 공개 범위를 담는다. + */ +export type VideoVisibilityResponseDto = { + /** + * 전환된 영상 ID + */ + videoId?: number; + /** + * 전환 후 공개 범위 (PUBLIC 또는 PRIVATE) + */ + visibility?: string; +}; + +export type ApiResponseDtoListZoneResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 격자 표시명 계산용 구역(zone). 정수 사각형 + 이름 + 소속 행정동. + */ +export type ZoneResponseDto = { + /** + * 안정 식별자 slug (zones.zone_key) — 클라이언트 참조·타이브레이크 기준 + */ + zoneKey?: string; + /** + * 구역명 (zones.name) + */ + name?: string; + /** + * 소속 행정동 코드 (zones.region_code, nullable) + */ + regionCode?: string; + /** + * 사각형 남단 행 (zones.min_grid_y) + */ + minGridY?: number; + /** + * 사각형 북단 행 = A행 (zones.max_grid_y) + */ + maxGridY?: number; + /** + * 사각형 서단 열 = 1열 (zones.min_grid_x) + */ + minGridX?: number; + /** + * 사각형 동단 열 (zones.max_grid_x) + */ + maxGridX?: number; + /** + * 겹침 결정성 우선순위 (zones.priority) + */ + priority?: number; +}; + +export type ApiResponseDtoVideoPlaybackResponseDto = { + developCode?: number; + message?: string; + body?: VideoPlaybackResponseDto; +}; + +/** + * 단건 영상 재생 조회 응답 + */ +export type VideoPlaybackResponseDto = { + /** + * 영상(방문 이벤트) ID + */ + videoId?: number; + /** + * 재생본 presigned GET URL. READY 아님·BLINDED(소유자)면 null + */ + playbackUrl?: string | null; + /** + * 썸네일 presigned GET URL. 썸네일 key 없음(READY 이전)이면 null + */ + thumbnailUrl?: string | null; + /** + * 이 영상이 속한 격자 ID + */ + gridId?: string; + /** + * 영상 길이(초, 최대 30) + */ + durationSec?: number; + /** + * 영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED) + */ + processingStatus?: string; + /** + * 공개 범위 (PUBLIC/PRIVATE) + */ + visibility?: string; + /** + * 영상 상태 (ACTIVE/BLINDED). 소유자가 블라인드 사유를 구분하는 축 + */ + status?: string; + /** + * 조회수 (이번 조회 증가 전 스냅샷) + */ + viewCount?: number; + /** + * 촬영 시각 (표시용) + */ + recordedAt?: string; + /** + * playbackUrl presign TTL(초). playbackUrl=null 이면 null + */ + expiresInSec?: number | null; +}; + +export type ApiResponseDtoListPlaceSearchResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 장소 검색 결과 1건. 선택 시 lat/lng 로 지도 이동 + gridId 로 격자 하이라이트를 한 번에 처리한다. + */ +export type PlaceSearchResponseDto = { + /** + * 장소명 (카카오 place_name) + */ + name?: string; + /** + * 표시용 주소 — 도로명 우선, 없으면 지번 (§D2) + */ + address?: string; + /** + * 위도 (WGS84, 카카오 y 직결 — 변환 없음) + */ + lat?: number; + /** + * 경도 (WGS84, 카카오 x) + */ + lng?: number; + /** + * 그 좌표의 격자 ID — FE 격자 하이라이트 키 (즉석 계산, 저장 아님) + */ + gridId?: string; +}; + +export type ApiResponseDtoRegionExploreResponseDto = { + developCode?: number; + message?: string; + body?: RegionExploreResponseDto; +}; + +/** + * 전역 탐색 격자 카드 + */ +export type ExploreGridResponseDto = { + /** + * 격자 ID — 카드 탭 시 격자 전역 영상 목록(MSG-237) 진입 키 + */ + gridId?: string; + /** + * 격자 위도 인덱스 (FE 지도 이동·라벨 조합) + */ + gridY?: number; + /** + * 격자 경도 인덱스 + */ + gridX?: number; + /** + * 그 격자의 게이트 통과 영상 수 — "N개 영상" + */ + videoCount?: number; + /** + * 커버 썸네일 presigned GET URL. READY 게이트라 non-null 기대(null 이면 null 통과) + */ + coverThumbnailUrl?: string; + /** + * 커버 영상 길이(초) — duration 뱃지 + */ + coverDurationSec?: number; +}; + +/** + * 행정동 격자 카드 리스트 + 헤더 카운트 + */ +export type RegionExploreResponseDto = { + /** + * 행정동 코드 (요청 에코) + */ + regionCode?: string; + /** + * 행정동 이름 — 미존재 코드면 null + */ + regionName?: string | null; + /** + * 게이트 통과 영상 ≥1 격자 수 — "이 지역 격자 N개" + */ + gridCount?: number; + /** + * 게이트 통과 영상 총수 — "영상 M개" + */ + videoCount?: number; + /** + * 격자 카드 (정렬·limit 적용 후). 없으면 빈 배열 + */ + grids?: Array; +}; + +export type ApiResponseDtoListRegionStatResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 한 행정동의 수집률. 사용자가 그 행정동에서 점령(수집)한 격자 수와 진행률. + */ +export type RegionStatResponseDto = { + /** + * 행정동 코드 (region_stats.region_code) + */ + regionCode?: string; + /** + * 행정동 이름 (regions.region_name) + */ + regionName?: string; + /** + * 상위 시군구 코드 (regions.parent_code) + */ + parentCode?: string; + /** + * 점령(수집)한 격자 수 + */ + collectedCount?: number; + /** + * 그 행정동 전체 격자 수(분모) + */ + totalCount?: number; + /** + * 수집률(%) — 100 상한 clamp + */ + progressRate?: number; + /** + * 수집률 캐시 기준 시각 + */ + updatedAt?: string; +}; + +export type ApiResponseDtoRegionStatResponseDto = { + developCode?: number; + message?: string; + body?: RegionStatResponseDto; +}; + +export type ApiResponseDtoRegionResponseDto = { + developCode?: number; + message?: string; + body?: RegionResponseDto; +}; + +/** + * 좌표를 포함하는 행정동. 포함 행정동이 없으면(바다·국외) body 가 null 이다. + */ +export type RegionResponseDto = { + /** + * 행정동 코드 (regions.region_code = adm_cd2) + */ + regionCode?: string; + /** + * 행정동 이름 (regions.region_name = adm_nm) + */ + regionName?: string; + /** + * 상위 시군구 코드 (regions.parent_code) + */ + parentCode?: string; +}; + +export type ApiResponseDtoListRegionGridCountResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 전체 지역 리스트 항목 (행정동별 격자 수) + */ +export type RegionGridCountResponseDto = { + /** + * 행정동 코드 — 선택 시 격자 카드 조회에 전달 + */ + regionCode?: string; + /** + * 행정동 이름 + */ + regionName?: string; + /** + * 그 행정동의 게이트 통과 격자 수 + */ + gridCount?: number; +}; + +export type ApiResponseDtoListMissionResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 이벤트(EVENT) — 격자 집합을 감싸는 경계 사각형 + */ +export type BoxShape = MissionShape & { + polygon?: Array; +}; + +/** + * 격자 중심점 + */ +export type Cell = { + gridId?: string; + lat?: number; + lon?: number; +}; + +/** + * 테마·지속(THEME·CONTINUOUS) — 각 격자 중심점 + */ +export type CellsShape = MissionShape & { + cells?: Array; +}; + +/** + * 좌표 한 점 + */ +export type LatLng = { + lat?: number; + lon?: number; +}; + +/** + * 활성 미션 하나 — 공통 필드 + 유형별 렌더 shape + */ +export type MissionResponseDto = { + /** + * 미션 id (missions.id) + */ + missionId?: number; + /** + * 미션 유형 — FE 렌더러 판별자 + */ + type?: 'COURSE' | 'AREA' | 'EVENT' | 'THEME' | 'CONTINUOUS' | 'POPUP'; + /** + * 미션 제목 + */ + title?: string; + /** + * 완료에 필요한 distinct 방문 격자 수(표시·판정 힌트, 판정은 MSG-223) + */ + targetCount?: number; + /** + * 시작 시각. NULL = 무기간(상시) + */ + startAt?: string; + /** + * 종료 시각. NULL = 무기간(상시) + */ + endAt?: string; + /** + * 유형별 렌더 shape 하나(type 에 대응하는 PATH/BOX/CELLS/REGION) + */ + shape?: BoxShape | CellsShape | PathShape | RegionShape; +}; + +/** + * 미션 유형별 렌더 shape (상위 type 으로 판별). PATH·BOX·CELLS·REGION 중 하나. + */ +export type MissionShape = unknown; + +/** + * 코스(COURSE) — GeoJSON LineString + seq순 포토스팟 마커 + */ +export type PathShape = MissionShape & { + /** + * 코스 라인 GeoJSON LineString 원문 + */ + line?: string; + spots?: Array; +}; + +/** + * 구역(AREA) — region_code 만(경계는 region API 로 별도 조회) + */ +export type RegionShape = MissionShape & { + regionCode?: string; +}; + +/** + * 코스 포토스팟 마커 + */ +export type Spot = { + gridId?: string; + lat?: number; + lon?: number; + seq?: number; +}; + +export type ApiResponseDtoHotZoneListResponseDto = { + developCode?: number; + message?: string; + body?: HotZoneListResponseDto; +}; + +/** + * 뷰포트 내 핫구역 목록 응답 (핫스코어 내림차순) + */ +export type HotZoneListResponseDto = { + /** + * 핫구역 목록 — 핫스코어 내림차순. 없으면 빈 배열 + */ + hotZones?: Array; +}; + +/** + * 핫구역 한 칸 — 최근 48시간 방문(업로드) 신호가 상위인 격자 + */ +export type HotZoneResponseDto = { + /** + * 격자 ID ("{grid_y}_{grid_x}" 포맷) + */ + gridId?: string; + /** + * 격자 세로 인덱스 (위도 기반 정수) + */ + gridY?: number; + /** + * 격자 가로 인덱스 (경도 기반 정수) + */ + gridX?: number; + /** + * 핫스코어 — 최근 48시간(8버킷) 방문 신호 합산 + */ + score?: number; +}; + +export type ApiResponseDtoOccupiedGridPageResponseDto = { + developCode?: number; + message?: string; + body?: OccupiedGridPageResponseDto; +}; + +/** + * 뷰포트 색칠 격자 페이지 응답 (커서 페이지네이션) + */ +export type OccupiedGridPageResponseDto = { + /** + * 이 페이지의 색칠 격자 목록 ((grid_y, grid_x) 오름차순) + */ + grids?: Array; + /** + * 다음 페이지 조회용 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null. + */ + nextCursor?: string | null; +}; + +/** + * 뷰포트 색칠 격자 한 칸 — 지도 렌더링용 위치 정보 + */ +export type OccupiedGridResponseDto = { + /** + * 격자 ID ("{grid_y}_{grid_x}" 포맷) + */ + gridId?: string; + /** + * 격자 세로 인덱스 (위도 기반 정수) + */ + gridY?: number; + /** + * 격자 가로 인덱스 (경도 기반 정수) + */ + gridX?: number; +}; + +export type ApiResponseDtoGridCellResponseDto = { + developCode?: number; + message?: string; + body?: GridCellResponseDto; +}; + +/** + * 단일 격자의 내 색칠(점령) 상태. 미점령이어도 404가 아니라 occupied=false로 응답한다. + */ +export type GridCellResponseDto = { + /** + * 격자 ID ("{grid_y}_{grid_x}" 포맷) + */ + gridId?: string; + /** + * 내가 이 격자를 점령(색칠)했는지 여부 + */ + occupied?: boolean; + /** + * 이 격자에 올린 내 영상 수 (미점령이면 0) + */ + videoCount?: number; +}; + +export type ApiResponseDtoGridVideoPageResponseDto = { + developCode?: number; + message?: string; + body?: GridVideoPageResponseDto; +}; + +/** + * 격자 전역 영상 목록 항목 + */ +export type GridGlobalVideoResponseDto = { + /** + * 영상 ID. 항목 탭 → 단건 재생(GET /api/videos/{videoId}) 진입 키 + */ + videoId?: number; + /** + * 썸네일 presigned GET URL. 목록은 READY 만 담겨 null 아님이 기대값이다 + */ + thumbnailUrl?: string; + /** + * 영상 길이(초, 최대 30) + */ + durationSec?: number; + /** + * 조회수 — 인기순 정렬 근거 + */ + viewCount?: number; + /** + * 촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다 + */ + recordedAt?: string; +}; + +/** + * 격자 전역 영상 목록 페이지 응답 (keyset 커서 페이지네이션) + */ +export type GridVideoPageResponseDto = { + /** + * 이 페이지의 전역 공개·READY 영상 (인기순). 없으면 빈 배열 + */ + videos?: Array; + /** + * 다음 페이지 존재 여부 (lookahead 판정) + */ + hasNext?: boolean; + /** + * 다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null. + */ + nextCursor?: string | null; +}; + +export type ApiResponseDtoListGridVideoResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 격자별 내 영상 리스트 항목 + */ +export type GridVideoResponseDto = { + /** + * 영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키 + */ + videoId?: number; + /** + * 썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null + */ + thumbnailUrl?: string | null; + /** + * 영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED) + */ + processingStatus?: string; + /** + * 영상 길이(초, 최대 30) + */ + durationSec?: number; + /** + * 업로드(방문) 시각 — 정렬 키 + */ + createdAt?: string; +}; + +export type ApiResponseDtoGridCoverVideoResponseDto = { + developCode?: number; + message?: string; + body?: GridCoverVideoResponseDto; +}; + +/** + * 격자 전역 대표 영상 + */ +export type GridCoverVideoResponseDto = { + /** + * 대표 영상 ID. 개별 재생 진입 키 + */ + videoId?: number; + /** + * 썸네일 presigned GET URL. 대표는 항상 READY 라 null 이 아니다 + */ + thumbnailUrl?: string; + /** + * 영상 길이(초, 최대 30) + */ + durationSec?: number; + /** + * 조회수 — 대표 선정 정렬 키 + */ + viewCount?: number; + /** + * 촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다 + */ + recordedAt?: string; +}; + +export type ApiResponseDtoListRegionVideoResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 동 단위 내 영상 리스트 항목 — 그 행정동 격자들에 올린 내 영상 하나. + */ +export type RegionVideoResponseDto = { + /** + * 영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키 + */ + videoId?: number; + /** + * 영상이 속한 격자 ID "{grid_y}_{grid_x}" — 항목별 격자 라벨·지도 이동용 + */ + gridId?: string; + /** + * 썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null + */ + thumbnailUrl?: string | null; + /** + * 영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED) + */ + processingStatus?: string; + /** + * 영상 길이(초, 최대 30) + */ + durationSec?: number; + /** + * 업로드(방문) 시각 — 정렬 키 + */ + createdAt?: string; +}; + +export type ApiResponseDtoCollectionSummaryResponseDto = { + developCode?: number; + message?: string; + body?: CollectionSummaryResponseDto; +}; + +/** + * 개인 도감 요약 — 점령한 격자 수·올린 영상 총합·방문한 행정동 수. + */ +export type CollectionSummaryResponseDto = { + /** + * 내가 점령한 격자 수 (도감 크기) + */ + totalGridCount?: number; + /** + * 내가 올린 영상 총합 (활성 영상만) + */ + totalVideoCount?: number; + /** + * 내가 방문한 서로 다른 행정동 수 + */ + visitedRegionCount?: number; +}; + +export type ApiResponseDtoListCollectionGridResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 갤러리 격자 항목 — 내가 수집한 격자 하나와 cover 썸네일. + */ +export type CollectionGridResponseDto = { + /** + * 격자 ID "{grid_y}_{grid_x}" + */ + gridId?: string; + /** + * 격자 Y 인덱스(지도 이동용, gridId 디코드값) + */ + gridY?: number; + /** + * 격자 X 인덱스(지도 이동용, gridId 디코드값) + */ + gridX?: number; + /** + * 최초 수집(점령) 시각 — 정렬 키 + */ + firstCollectedAt?: string; + /** + * 마지막 방문(업로드) 시각 + */ + lastUploadedAt?: string; + /** + * 그 격자 내 내 영상 수 + */ + videoCount?: number; + /** + * cover 영상 ID(없으면 null) + */ + coverVideoId?: number | null; + /** + * cover 썸네일 presigned GET URL(없거나 READY 이전이면 null) + */ + coverThumbnailUrl?: string | null; + /** + * 격자 중심점 행정동 이름(무귀속/미판정이면 null) + */ + regionName?: string | null; +}; + +export type ApiResponseDtoListMyBadgeResponseDto = { + developCode?: number; + message?: string; + body?: Array; +}; + +/** + * 내 뱃지 목록 행 — 획득+미획득 전체 + */ +export type MyBadgeResponseDto = { + /** + * 뱃지 ID + */ + badgeId?: number; + /** + * 뱃지 code + */ + code?: string; + /** + * 표시명 + */ + name?: string; + /** + * 설명 + */ + description?: string; + /** + * 아이콘 URL (에셋 확정 전 null) + */ + iconUrl?: string; + /** + * 획득 여부 + */ + earned?: boolean; + /** + * 획득 시각 — 미획득이면 null + */ + earnedAt?: string; + /** + * 미확인(새 뱃지) 여부 — 미획득이면 false + */ + isNew?: boolean; + /** + * 대표 뱃지 순서(1·2) — 대표 아니면 null + */ + featuredRank?: number; +}; + +export type DeleteData = { + body?: never; + path: { + /** + * 삭제할 영상 ID + */ + videoId: number; + }; + query?: never; + url: '/api/videos/{videoId}'; +}; + +export type DeleteResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetPlaybackData = { + body?: never; + path: { + /** + * 재생할 영상 ID + */ + videoId: number; + }; + query?: never; + url: '/api/videos/{videoId}'; +}; + +export type GetPlaybackResponses = { + /** + * OK + */ + 200: ApiResponseDtoVideoPlaybackResponseDto; +}; + +export type GetPlaybackResponse = GetPlaybackResponses[keyof GetPlaybackResponses]; + +export type ReplaceData = { + body: VideoReplaceRequestDto; + path: { + /** + * 교체할 영상 ID + */ + videoId: number; + }; + query?: never; + url: '/api/videos/{videoId}'; +}; + +export type ReplaceResponses = { + /** + * OK + */ + 200: ApiResponseDtoVideoReplaceResponseDto; +}; + +export type ReplaceResponse = ReplaceResponses[keyof ReplaceResponses]; + +export type ReplaceFeaturedData = { + body: FeaturedBadgeRequestDto; + path?: never; + query?: never; + url: '/api/badges/featured'; +}; + +export type ReplaceFeaturedResponses = { + /** + * OK + */ + 200: ApiResponseDtoListFeaturedBadgeResponseDto; +}; + +export type ReplaceFeaturedResponse = ReplaceFeaturedResponses[keyof ReplaceFeaturedResponses]; + +export type UploadData = { + body: VideoUploadRequestDto; + path?: never; + query?: never; + url: '/api/videos'; +}; + +export type UploadResponses = { + /** + * OK + */ + 200: ApiResponseDtoVideoUploadResponseDto; +}; + +export type UploadResponse = UploadResponses[keyof UploadResponses]; + +export type IssuePresignedUrlData = { + body: PresignedUrlRequestDto; + path?: never; + query?: never; + url: '/api/videos/presigned-url'; +}; + +export type IssuePresignedUrlResponses = { + /** + * OK + */ + 200: ApiResponseDtoPresignedUrlResponseDto; +}; + +export type IssuePresignedUrlResponse = IssuePresignedUrlResponses[keyof IssuePresignedUrlResponses]; + +export type SignupData = { + body: SignupRequestDto; + path?: never; + query?: never; + url: '/api/auth/signup'; +}; + +export type SignupResponses = { + /** + * OK + */ + 200: ApiResponseDtoSignupResponseDto; +}; + +export type SignupResponse = SignupResponses[keyof SignupResponses]; + +export type ReissueData = { + body?: ReissueRequestDto; + headers?: { + /** + * 클라이언트 유형 (web|app, 기본 web) + */ + 'X-Client-Type'?: string; + }; + path?: never; + query?: never; + url: '/api/auth/reissue'; +}; + +export type ReissueResponses = { + /** + * OK + */ + 200: ApiResponseDtoReissueResponseDto; +}; + +export type ReissueResponse = ReissueResponses[keyof ReissueResponses]; + +export type OauthLoginData = { + body: OidcLoginRequestDto; + headers?: { + /** + * 클라이언트 유형 (web|app, 기본 web) + */ + 'X-Client-Type'?: string; + /** + * 디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다. + */ + 'X-Device-Id'?: string; + }; + path: { + /** + * 소셜 제공자 + */ + provider: string; + }; + query?: never; + url: '/api/auth/oauth/{provider}'; +}; + +export type OauthLoginResponses = { + /** + * OK + */ + 200: ApiResponseDtoLoginResponseDto; +}; + +export type OauthLoginResponse = OauthLoginResponses[keyof OauthLoginResponses]; + +export type LogoutData = { + body?: never; + headers?: { + Authorization?: string; + /** + * 디바이스 식별자. 없으면 모든 디바이스 세션 삭제(로그아웃-올). + */ + 'X-Device-Id'?: string; + }; + path?: never; + query?: never; + url: '/api/auth/logout'; +}; + +export type LogoutResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type LoginData = { + body: LoginRequestDto; + headers?: { + /** + * 클라이언트 유형 (web|app, 기본 web) + */ + 'X-Client-Type'?: string; + /** + * 디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다. + */ + 'X-Device-Id'?: string; + }; + path?: never; + query?: never; + url: '/api/auth/login'; +}; + +export type LoginResponses = { + /** + * OK + */ + 200: ApiResponseDtoLoginResponseDto; +}; + +export type LoginResponse = LoginResponses[keyof LoginResponses]; + +export type SocialLoginData = { + body: DevSocialLoginRequestDto; + headers?: { + /** + * 디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환. + */ + 'X-Device-Id'?: string; + }; + path?: never; + query?: never; + url: '/api/auth/dev/social-login'; +}; + +export type SocialLoginResponses = { + /** + * OK + */ + 200: ApiResponseDtoLoginResponseDto; +}; + +export type SocialLoginResponse = SocialLoginResponses[keyof SocialLoginResponses]; + +export type SetVisibilityData = { + body: VideoVisibilityRequestDto; + path: { + /** + * 공개 범위를 전환할 영상 ID + */ + videoId: number; + }; + query?: never; + url: '/api/videos/{videoId}/visibility'; +}; + +export type SetVisibilityResponses = { + /** + * OK + */ + 200: ApiResponseDtoVideoVisibilityResponseDto; +}; + +export type SetVisibilityResponse = SetVisibilityResponses[keyof SetVisibilityResponses]; + +export type GetZonesData = { + body?: never; + path?: never; + query?: never; + url: '/api/zones'; +}; + +export type GetZonesResponses = { + /** + * OK + */ + 200: ApiResponseDtoListZoneResponseDto; +}; + +export type GetZonesResponse = GetZonesResponses[keyof GetZonesResponses]; + +export type SearchPlacesData = { + body?: never; + path?: never; + query: { + /** + * 검색어 (자유 텍스트 장소명) + */ + q: string; + }; + url: '/api/search/places'; +}; + +export type SearchPlacesResponses = { + /** + * OK + */ + 200: ApiResponseDtoListPlaceSearchResponseDto; +}; + +export type SearchPlacesResponse = SearchPlacesResponses[keyof SearchPlacesResponses]; + +export type GetRegionGridsData = { + body?: never; + path: { + /** + * 행정동 코드 — reverse-geocode·전체 지역 리스트의 regionCode 를 그대로 전달 + */ + regionCode: string; + }; + query?: { + /** + * 정렬 — POPULAR(조회수 합)·LATEST(최신 공개 영상). 대문자 전용이며 소문자 포함 무효 값은 400 이다 + */ + sort?: 'POPULAR' | 'LATEST'; + /** + * 카드 수 상한 — 지도 홈 패널은 3. 생략하면 전부, 1 미만은 1 로 보정한다 + */ + limit?: number; + }; + url: '/api/regions/{regionCode}/grids'; +}; + +export type GetRegionGridsResponses = { + /** + * OK + */ + 200: ApiResponseDtoRegionExploreResponseDto; +}; + +export type GetRegionGridsResponse = GetRegionGridsResponses[keyof GetRegionGridsResponses]; + +export type GetStatsData = { + body?: never; + path?: never; + query?: { + /** + * 상위 시군구 코드. 생략하면 전국. 실존하지 않으면 6404 + */ + parentCode?: string; + /** + * true=수집한 행정동만, false=손댄 행정동 전부(롤백 0-row 포함) + */ + collectedOnly?: boolean; + }; + url: '/api/regions/stats'; +}; + +export type GetStatsResponses = { + /** + * OK + */ + 200: ApiResponseDtoListRegionStatResponseDto; +}; + +export type GetStatsResponse = GetStatsResponses[keyof GetStatsResponses]; + +export type GetStatByPointData = { + body?: never; + path?: never; + query?: { + /** + * 위도 + */ + lat?: number; + /** + * 경도 + */ + lon?: number; + }; + url: '/api/regions/stats/by-point'; +}; + +export type GetStatByPointResponses = { + /** + * OK + */ + 200: ApiResponseDtoRegionStatResponseDto; +}; + +export type GetStatByPointResponse = GetStatByPointResponses[keyof GetStatByPointResponses]; + +export type GetStatByGridData = { + body?: never; + path?: never; + query?: { + /** + * 격자 ID "{grid_y}_{grid_x}" + */ + gridId?: string; + }; + url: '/api/regions/stats/by-grid'; +}; + +export type GetStatByGridResponses = { + /** + * OK + */ + 200: ApiResponseDtoRegionStatResponseDto; +}; + +export type GetStatByGridResponse = GetStatByGridResponses[keyof GetStatByGridResponses]; + +export type ReverseGeocodeData = { + body?: never; + path?: never; + query?: { + /** + * 위도 + */ + lat?: number; + /** + * 경도 + */ + lon?: number; + }; + url: '/api/regions/reverse-geocode'; +}; + +export type ReverseGeocodeResponses = { + /** + * OK + */ + 200: ApiResponseDtoRegionResponseDto; +}; + +export type ReverseGeocodeResponse = ReverseGeocodeResponses[keyof ReverseGeocodeResponses]; + +export type GetExploreRegionsData = { + body?: never; + path?: never; + query?: never; + url: '/api/regions/explore'; +}; + +export type GetExploreRegionsResponses = { + /** + * OK + */ + 200: ApiResponseDtoListRegionGridCountResponseDto; +}; + +export type GetExploreRegionsResponse = GetExploreRegionsResponses[keyof GetExploreRegionsResponses]; + +export type GetActiveMissionsData = { + body?: never; + path?: never; + query?: never; + url: '/api/missions/active'; +}; + +export type GetActiveMissionsResponses = { + /** + * OK + */ + 200: ApiResponseDtoListMissionResponseDto; +}; + +export type GetActiveMissionsResponse = GetActiveMissionsResponses[keyof GetActiveMissionsResponses]; + +export type GetHotZonesData = { + body?: never; + path?: never; + query: { + /** + * 남서 모서리 위도 + */ + swLat: number; + /** + * 남서 모서리 경도 + */ + swLng: number; + /** + * 북동 모서리 위도 + */ + neLat: number; + /** + * 북동 모서리 경도 + */ + neLng: number; + }; + url: '/api/hotzones'; +}; + +export type GetHotZonesResponses = { + /** + * OK + */ + 200: ApiResponseDtoHotZoneListResponseDto; +}; + +export type GetHotZonesResponse = GetHotZonesResponses[keyof GetHotZonesResponses]; + +export type GetOccupiedInViewportData = { + body?: never; + path?: never; + query?: { + /** + * 남서 모서리 위도 + */ + swLat?: number; + /** + * 남서 모서리 경도 + */ + swLng?: number; + /** + * 북동 모서리 위도 + */ + neLat?: number; + /** + * 북동 모서리 경도 + */ + neLng?: number; + /** + * 다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략 + */ + cursor?: string; + /** + * 페이지 크기 (기본 1000, 최대 5000) + */ + size?: number; + }; + url: '/api/grids'; +}; + +export type GetOccupiedInViewportResponses = { + /** + * OK + */ + 200: ApiResponseDtoOccupiedGridPageResponseDto; +}; + +export type GetOccupiedInViewportResponse = GetOccupiedInViewportResponses[keyof GetOccupiedInViewportResponses]; + +export type GetCellData = { + body?: never; + path: { + /** + * 격자 ID ("{grid_y}_{grid_x}" 포맷) + */ + gridId: string; + }; + query?: never; + url: '/api/grids/{gridId}'; +}; + +export type GetCellResponses = { + /** + * OK + */ + 200: ApiResponseDtoGridCellResponseDto; +}; + +export type GetCellResponse = GetCellResponses[keyof GetCellResponses]; + +export type GetGridGlobalVideosData = { + body?: never; + path: { + /** + * 격자 ID + */ + gridId: string; + }; + query?: { + /** + * 직전 응답의 nextCursor (opaque). 생략하면 첫 페이지 + */ + cursor?: string; + /** + * 페이지 크기 (1~50, 기본 20) + */ + size?: number; + }; + url: '/api/grids/{gridId}/videos'; +}; + +export type GetGridGlobalVideosResponses = { + /** + * OK + */ + 200: ApiResponseDtoGridVideoPageResponseDto; +}; + +export type GetGridGlobalVideosResponse = GetGridGlobalVideosResponses[keyof GetGridGlobalVideosResponses]; + +export type GetGridVideosData = { + body?: never; + path: { + /** + * 격자 ID + */ + gridId: string; + }; + query?: never; + url: '/api/grids/{gridId}/my-videos'; +}; + +export type GetGridVideosResponses = { + /** + * OK + */ + 200: ApiResponseDtoListGridVideoResponseDto; +}; + +export type GetGridVideosResponse = GetGridVideosResponses[keyof GetGridVideosResponses]; + +export type GetGridCoverData = { + body?: never; + path: { + /** + * 격자 ID + */ + gridId: string; + }; + query?: never; + url: '/api/grids/{gridId}/cover'; +}; + +export type GetGridCoverResponses = { + /** + * OK + */ + 200: ApiResponseDtoGridCoverVideoResponseDto; +}; + +export type GetGridCoverResponse = GetGridCoverResponses[keyof GetGridCoverResponses]; + +export type GetRegionVideosData = { + body?: never; + path?: never; + query: { + /** + * 행정동 코드 — by-grid 응답의 regionCode 를 그대로 전달 + */ + regionCode: string; + }; + url: '/api/collections/videos'; +}; + +export type GetRegionVideosResponses = { + /** + * OK + */ + 200: ApiResponseDtoListRegionVideoResponseDto; +}; + +export type GetRegionVideosResponse = GetRegionVideosResponses[keyof GetRegionVideosResponses]; + +export type GetSummaryData = { + body?: never; + path?: never; + query?: never; + url: '/api/collections/summary'; +}; + +export type GetSummaryResponses = { + /** + * OK + */ + 200: ApiResponseDtoCollectionSummaryResponseDto; +}; + +export type GetSummaryResponse = GetSummaryResponses[keyof GetSummaryResponses]; + +export type GetCollectionGridsData = { + body?: never; + path?: never; + query?: never; + url: '/api/collections/grids'; +}; + +export type GetCollectionGridsResponses = { + /** + * OK + */ + 200: ApiResponseDtoListCollectionGridResponseDto; +}; + +export type GetCollectionGridsResponse = GetCollectionGridsResponses[keyof GetCollectionGridsResponses]; + +export type FindMyBadgesData = { + body?: never; + path?: never; + query?: never; + url: '/api/badges'; +}; + +export type FindMyBadgesResponses = { + /** + * OK + */ + 200: ApiResponseDtoListMyBadgeResponseDto; +}; + +export type FindMyBadgesResponse = FindMyBadgesResponses[keyof FindMyBadgesResponses]; diff --git a/package.json b/package.json index 637b4157..c983a9b8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "pnpm -r run lint", "typecheck": "pnpm -r run typecheck", "test": "pnpm --filter web test run", + "openapi-ts": "pnpm --filter web openapi-ts", "storybook": "pnpm --filter @fillmap/ui-web storybook", "build-storybook": "pnpm --filter @fillmap/ui-web build-storybook", "prepare": "husky" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fa4891b..ef2032c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@fillmap/tailwind-preset': specifier: workspace:* version: link:../../packages/tailwind-preset + '@hey-api/openapi-ts': + specifier: ^0.99.0 + version: 0.99.0(typescript@6.0.3) '@tailwindcss/vite': specifier: ^4.3.2 version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) @@ -666,6 +669,31 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@hey-api/codegen-core@0.9.1': + resolution: {integrity: sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==} + engines: {node: '>=22.18.0'} + + '@hey-api/json-schema-ref-parser@1.4.4': + resolution: {integrity: sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==} + engines: {node: '>=22.18.0'} + + '@hey-api/openapi-ts@0.99.0': + resolution: {integrity: sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==} + engines: {node: '>=22.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' + + '@hey-api/shared@0.5.0': + resolution: {integrity: sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==} + engines: {node: '>=22.18.0'} + + '@hey-api/spec-types@0.2.0': + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + + '@hey-api/types@0.1.4': + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -717,6 +745,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -2274,6 +2309,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2309,6 +2352,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2334,6 +2381,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2342,10 +2393,17 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + conf@10.2.0: resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} engines: {node: '>=12'} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -2460,6 +2518,9 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2468,6 +2529,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2683,6 +2747,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2797,6 +2864,13 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3003,6 +3077,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true @@ -3282,6 +3360,9 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3399,6 +3480,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3414,6 +3498,9 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkg-up@3.1.0: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} @@ -3482,6 +3569,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -3556,6 +3646,10 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + recast@0.23.12: resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==} engines: {node: '>= 4'} @@ -3572,6 +3666,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -3615,6 +3712,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -4605,6 +4707,56 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} + '@hey-api/codegen-core@0.9.1': + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4 + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + + '@hey-api/json-schema-ref-parser@1.4.4': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@hey-api/openapi-ts@0.99.0(typescript@6.0.3)': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/shared': 0.5.0 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 15.0.0 + get-tsconfig: 4.14.0 + typescript: 6.0.3 + transitivePeerDependencies: + - magicast + + '@hey-api/shared@0.5.0': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.8.4 + transitivePeerDependencies: + - magicast + + '@hey-api/spec-types@0.2.0': + dependencies: + '@hey-api/types': 0.1.4 + + '@hey-api/types@0.1.4': {} + '@hono/node-server@1.19.14(hono@4.12.28)': dependencies: hono: 4.12.28 @@ -4652,6 +4804,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} + + '@lukeed/ms@2.0.2': {} + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.28) @@ -6176,6 +6332,21 @@ snapshots: bytes@3.1.2: {} + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6209,6 +6380,10 @@ snapshots: check-error@2.1.3: {} + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -6229,10 +6404,14 @@ snapshots: color-name@1.1.4: {} + color-support@1.1.3: {} + commander@11.1.0: {} commander@14.0.3: {} + commander@15.0.0: {} + conf@10.2.0: dependencies: ajv: 8.20.0 @@ -6246,6 +6425,8 @@ snapshots: pkg-up: 3.1.0 semver: 7.8.5 + confbox@0.2.4: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -6327,10 +6508,14 @@ snapshots: define-lazy-prop@3.0.0: {} + defu@6.1.7: {} + depd@2.0.0: {} dequal@2.0.3: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -6616,6 +6801,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.1.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -6729,6 +6916,12 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@3.3.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6872,6 +7065,10 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -7092,6 +7289,8 @@ snapshots: obug@2.1.3: {} + ohash@2.0.11: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -7258,6 +7457,8 @@ snapshots: pathval@2.0.1: {} + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -7266,6 +7467,12 @@ snapshots: pkce-challenge@5.0.1: {} + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + pkg-up@3.1.0: dependencies: find-up: 3.0.0 @@ -7386,6 +7593,11 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + react-docgen-typescript@2.4.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -7460,6 +7672,8 @@ snapshots: react@19.2.7: {} + readdirp@5.0.0: {} + recast@0.23.12: dependencies: ast-types: 0.16.1 @@ -7477,6 +7691,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -7538,6 +7754,8 @@ snapshots: semver@6.3.1: {} + semver@7.8.4: {} + semver@7.8.5: {} send@1.2.1: From 8b341d46ddedb62b93bb576c66b257e0601902f1 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 1 Aug 2026 20:29:03 +0900 Subject: [PATCH 115/281] =?UTF-8?q?MSG-289=20refactor:=20=EB=AA=A9=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AA=85=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20-=20=EC=83=9D=EC=84=B1=20=ED=83=80=EC=9E=85=20satis?= =?UTF-8?q?fies=20=EC=97=B0=EA=B2=B0=C2=B7=ED=95=84=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EC=B9=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/web/src/entities/cell/model/cell.ts | 82 ++++++++++---- .../entities/cell/model/mock-cells.test.ts | 2 +- .../web/src/entities/cell/model/mock-cells.ts | 52 +++++---- apps/web/src/entities/dex/model/dex.ts | 90 ++++++++------- .../src/entities/dex/model/mock-dex.test.ts | 36 +++--- apps/web/src/entities/dex/model/mock-dex.ts | 107 +++++++++++------- .../entities/profile/model/mock-profile.ts | 6 + .../web/src/features/dex/model/badges.test.ts | 18 +-- .../features/dex/model/dex-summary.test.ts | 46 ++++---- .../web/src/features/dex/model/dex-summary.ts | 12 +- .../src/features/dex/model/gallery.test.ts | 48 ++++---- apps/web/src/features/dex/model/gallery.ts | 14 +-- .../features/dex/model/use-dex-query.test.ts | 6 +- .../dex/model/use-gallery-query.test.ts | 8 +- .../explore/model/cell-detail-store.test.ts | 28 ++--- .../explore/model/cell-detail-store.ts | 6 +- .../map-home/model/grid-overlay.test.ts | 18 +-- .../features/map-home/model/grid-overlay.ts | 2 +- .../map-home/model/home-cell-detail.test.ts | 54 +++++---- .../map-home/model/home-cell-detail.ts | 20 ++-- .../map-home/model/theme-feed.test.ts | 42 +++---- .../src/features/map-home/model/theme-feed.ts | 8 +- .../map-home/model/theme-overlay.test.ts | 6 +- .../features/map-home/model/theme-overlay.ts | 4 +- .../src/features/map-home/model/theme.test.ts | 2 +- .../map-home/model/upload-hours.test.ts | 22 ++-- .../features/map-home/model/upload-hours.ts | 4 +- .../model/video-mini-panel-store.test.ts | 14 +-- apps/web/src/pages/dex/DexPanel.tsx | 8 +- .../src/pages/dex/badge-tab.smoke.test.tsx | 4 +- .../src/pages/dex/dex-panel.smoke.test.tsx | 12 +- .../src/pages/dex/gallery-tab.smoke.test.tsx | 80 ++++++------- apps/web/src/pages/dex/ui/BadgeTabBody.tsx | 2 +- apps/web/src/pages/dex/ui/GalleryTabBody.tsx | 2 +- .../web/src/pages/dex/ui/GalleryThumbnail.tsx | 6 +- apps/web/src/pages/dex/ui/RecentCellRow.tsx | 4 +- apps/web/src/pages/map-home/MapHomePage.tsx | 8 +- .../pages/map-home/ui/HomeCellDetailPanel.tsx | 4 +- .../src/pages/map-home/ui/ThemeFeedPanel.tsx | 2 +- .../src/pages/map-home/ui/VideoMiniPanel.tsx | 2 +- .../src/pages/map-home/ui/VideoOwnerMeta.tsx | 6 +- .../ui/feed-video-card.smoke.test.tsx | 6 +- .../ui/theme-feed-panel.smoke.test.tsx | 18 +-- .../ui/video-mini-panel.smoke.test.tsx | 12 +- .../widgets/cell-detail/CellDetailSheet.tsx | 14 +-- docs/decisions/DECISIONS.md | 2 + 46 files changed, 516 insertions(+), 433 deletions(-) diff --git a/apps/web/src/entities/cell/model/cell.ts b/apps/web/src/entities/cell/model/cell.ts index 070295df..a51fdb8c 100644 --- a/apps/web/src/entities/cell/model/cell.ts +++ b/apps/web/src/entities/cell/model/cell.ts @@ -1,4 +1,10 @@ -/** 위경도 좌표 (플랫폼 중립) */ +import type { GridGlobalVideoResponseDto } from "@/shared/api/generated/types.gen"; + +/** + * 위경도 좌표 (플랫폼 중립). + * 명세(shared/api/generated)의 동명 `LatLng`(미션 도형용, `lon` 사용)과 다른 타입이다 — + * 프론트 도메인은 `lng`를 쓴다. import 시 혼동 주의 (MSG-289 리스크 3). + */ export interface LatLng { lat: number; lng: number; @@ -10,19 +16,32 @@ export interface Bounds { ne: LatLng; } -/** 격자에 속한 개별 영상 (상세 시트 "이 격자의 영상" 리스트 항목, MSG-115) */ -export interface CellVideo { - id: string; - /** 영상 제목 */ +/** + * 명세 대응 필드 (MSG-289) — `GridGlobalVideoResponseDto`에서 type-only 파생. + * 응답 DTO는 전 필드 optional이라(백엔드 required 미명시) 화면 계약이 요구하는 + * 필드를 Required로 승격한다. 명세 필드명 변경·제거 시 이 Pick이 typecheck로 잡는다. + * - videoId: 영상 ID (number) — 단건 재생(GET /api/videos/{videoId}) 진입 키 + * - viewCount: 조회수(원시 값) — 표시 시 formatViewCount로 축약 + * - recordedAt: 촬영 시각 (date-time) — 표시 시 formatRelativeTime으로 상대 시간화. + * 명세는 타임존 없는 로컬 표기("2026-07-20T18:03:11"), 목은 UTC ISO — 실연동 시 오차 여지 (리스크 4) + * - durationSec: 영상 길이(초, 명세상 최대 30) + * - thumbnailUrl: 썸네일 URL — 없으면 placeholder 표시 + */ +type CellVideoSpecFields = Required< + Pick< + GridGlobalVideoResponseDto, + "videoId" | "viewCount" | "recordedAt" | "durationSec" + > +> & + Pick; + +/** + * FE 확장 필드 — 명세(GridGlobalVideoResponseDto)에 대응이 없는 화면 전용 필드 (MSG-289). + * title은 명세 전체에 영상 제목 필드가 없어 유지한다 — 백엔드 환류 후보 (리스크 2). + */ +export interface CellVideoExtension { + /** 영상 제목 — 명세 부재, FE 확장 (백엔드 환류 후보) */ title: string; - /** 조회수(원시 값) — 표시 시 formatViewCount로 축약 */ - viewCount: number; - /** 업로드 시각 (ISO 8601) — 표시 시 formatRelativeTime으로 상대 시간화 */ - uploadedAt: string; - /** 영상 길이(초) */ - durationSec: number; - /** 썸네일 URL — 없으면 placeholder 표시 */ - thumbnailSrc?: string; /** * 업로더 핸들 (표시 문자열, 예: "@minji_b") — 홈 셀 상세의 다른 사용자 카드 메타 (MSG-253). * 선택 필드로 추가만 — 기존 feature(explore·dex) 표시에는 영향 없음 @@ -30,35 +49,48 @@ export interface CellVideo { uploaderHandle?: string; /** * 재생 소스 URL — 홈 미니 디테일 패널의 HTML5 video src (MSG-277 3차 AC 5·7). + * 명세에선 목록 응답에 없고 단건 `VideoPlaybackResponseDto.playbackUrl`에만 존재 — FE 확장. * 실 스트리밍 전까지 목은 외부 CC0 샘플을 순환 배정한다. 선택 필드로 추가만 — * 기존 feature(explore·dex) 표시에는 영향 없음. 없으면 플레이스홀더 표시 */ videoSrc?: string; } -/** 격자 도메인 모델 */ +/** 격자에 속한 개별 영상 (상세 시트 "이 격자의 영상" 리스트 항목, MSG-115) = 명세 대응 + FE 확장 */ +export type CellVideo = CellVideoSpecFields & CellVideoExtension; + +/** + * 격자 도메인 모델 — FE 화면 집계 확장 타입 (MSG-289). + * 명세에 단일 대응 스키마가 없다 — `ExploreGridResponseDto`(격자 카드) + `RegionResponseDto`(행정동) + * + `RegionStatResponseDto`(수집률) + `GridVideoPageResponseDto`(영상 목록)의 화면 집계라 + * satisfies 연결 없이 유지한다. 필드별 명세 출처는 각 필드 주석 참조. + */ export interface Cell { + /** + * 격자 id — mock 체계 "A-14". 명세는 "{gridY}_{gridX}"(예: "41642_110458") — + * 화면 라벨·grid 로직(MSG-263/264)과 얽혀 있어 체계 전환은 실연동 후속 티켓 (MSG-289 질문 5 승인) + */ id: string; - /** 지역명 + 코드 (예: "서면 A-14") */ + /** 지역명 + 코드 (예: "서면 A-14") — 명세 출처: RegionResponseDto.regionName + gridId 파생 */ label: string; - /** 행정구(區) 이름 (예: "부산진구") — 지역 필터 매칭 키 (MSG-114 D1) */ + /** 행정구(區) 이름 (예: "부산진구") — 지역 필터 매칭 키 (MSG-114 D1). 명세 출처: RegionResponseDto */ district: string; - /** 격자 중심 좌표 */ + /** 격자 중심 좌표 — 명세 출처: ExploreGridResponseDto.gridY/gridX 디코드 */ center: LatLng; - /** 격자에 속한 영상 수 */ + /** 격자에 속한 영상 수 — 명세 출처: ExploreGridResponseDto.videoCount */ videoCount: number; - /** 격자 생성 시각 (ISO 8601) — "최신순" 정렬 기준 (D3) */ + /** 격자 생성 시각 (ISO 8601) — "최신순" 정렬 기준 (D3). 명세 대응 없음 (FE 확장) */ createdAt: string; - /** 대표 영상 길이(초) — 카드 길이 배지용. 없으면 배지 미표시 (S6) */ + /** 대표 영상 길이(초) — 카드 길이 배지용. 없으면 배지 미표시 (S6). 명세 출처: ExploreGridResponseDto.coverDurationSec */ durationSec?: number; - /** 상세 위치 문자열 (예: "부산 부산진구 서면") — 상세 시트 표시 (MSG-115) */ + /** 상세 위치 문자열 (예: "부산 부산진구 서면") — 상세 시트 표시 (MSG-115). 명세 출처: RegionResponseDto.regionName 파생 */ location: string; - /** 최근 업로드 시각 (ISO 8601) — 상세 시트 상대 시간 표시 (MSG-115) */ + /** 최근 업로드 시각 (ISO 8601) — 상세 시트 상대 시간 표시 (MSG-115). 명세 대응 없음 (FE 확장) */ recentUploadedAt: string; - /** 담수율(%) — 상세 시트 통계 (MSG-115) */ + /** 담수율(%) — 상세 시트 통계 (MSG-115). 명세 출처: RegionStatResponseDto.progressRate */ fillRate: number; - /** 격자 누적 조회수(원시 값) — 상세 시트 통계, 표시 시 축약 (MSG-115) */ + /** 격자 누적 조회수(원시 값) — 상세 시트 통계, 표시 시 축약 (MSG-115). 명세 대응 없음 (FE 확장) */ viewCount: number; - /** 개별 영상 목록 — 상세 시트 리스트. 대표 영상은 videos[0] (MSG-115) */ + /** 개별 영상 목록 — 상세 시트 리스트. 대표 영상은 videos[0] (MSG-115). 명세 출처: GridVideoPageResponseDto.videos */ videos: CellVideo[]; } diff --git a/apps/web/src/entities/cell/model/mock-cells.test.ts b/apps/web/src/entities/cell/model/mock-cells.test.ts index 01514647..52eedbad 100644 --- a/apps/web/src/entities/cell/model/mock-cells.test.ts +++ b/apps/web/src/entities/cell/model/mock-cells.test.ts @@ -11,7 +11,7 @@ describe("MOCK_CELLS videoSrc — CC0 샘플 순환 (3차 AC 7)", () => { expect(videos.length).toBeGreaterThan(0); for (const video of videos) { - expect(video.videoSrc, `${video.id}의 videoSrc`).toMatch(/^https:\/\//); + expect(video.videoSrc, `${video.videoId}의 videoSrc`).toMatch(/^https:\/\//); } }); diff --git a/apps/web/src/entities/cell/model/mock-cells.ts b/apps/web/src/entities/cell/model/mock-cells.ts index cb745c52..3f65962d 100644 --- a/apps/web/src/entities/cell/model/mock-cells.ts +++ b/apps/web/src/entities/cell/model/mock-cells.ts @@ -1,3 +1,4 @@ +import type { GridGlobalVideoResponseDto } from "@/shared/api/generated/types.gen"; import type { Cell, CellVideo } from "./cell"; const MINUTE = 60_000; @@ -14,7 +15,8 @@ const VIDEO_TITLES = [ "노을 지는 광안대교뷰", "비 오는 날 우산 씬", ]; -const VIDEO_DURATIONS = [42, 96, 27, 184, 63]; +// 명세 제약 "최대 30초" 이내로 정렬 (MSG-289 질문 4 승인 a — duration 표시값 변경 허용) +const VIDEO_DURATIONS = [12, 26, 9, 30, 21]; // 1만 이상 값(12000)을 포함해 홈 피드의 "1.2만" 축약(MSG-277 AC 6)이 화면에서 시연되게 한다 const VIDEO_VIEWS = [214, 1400, 58, 12000, 320]; const VIDEO_AGES = [5 * MINUTE, 3 * HOUR, 21 * HOUR, 2 * DAY, 6 * DAY]; @@ -38,14 +40,23 @@ const VIDEO_SOURCES = [ "https://mdn.github.io/shared-assets/videos/friday.mp4", ]; -/** 격자당 대표 리스트에 노출할 개별 영상 표본을 만든다 (전체 videoCount와 별개인 최근 표본). */ -const buildVideos = (cellId: string, sampleSize: number): CellVideo[] => +/** + * 격자당 대표 리스트에 노출할 개별 영상 표본을 만든다 (전체 videoCount와 별개인 최근 표본). + * 명세 대응 필드는 satisfies로 GridGlobalVideoResponseDto에 연결한다 (MSG-289 AC 3) — + * 명세 필드명 변경·타입 변경 시 재생성 후 여기서 typecheck가 실패한다. + * videoId는 격자 순번 기반 결정적 번호(격자별 100 단위 블록) — 기존 "A-14-v1" 문자열 체계를 + * 명세대로 number로 전환 (추정 3 승인). dex CollectedVideo.videoId 매칭 키와 연쇄된다. + */ +const buildVideos = (videoIdBase: number, sampleSize: number): CellVideo[] => Array.from({ length: sampleSize }, (_, i) => ({ - id: `${cellId}-v${i + 1}`, + ...({ + videoId: videoIdBase + i + 1, + viewCount: VIDEO_VIEWS[i % VIDEO_VIEWS.length], + recordedAt: isoAgo(VIDEO_AGES[i % VIDEO_AGES.length]), + durationSec: VIDEO_DURATIONS[i % VIDEO_DURATIONS.length], + } satisfies GridGlobalVideoResponseDto), + // FE 확장 필드 (CellVideoExtension) — 명세 대응 없음 title: VIDEO_TITLES[i % VIDEO_TITLES.length], - viewCount: VIDEO_VIEWS[i % VIDEO_VIEWS.length], - uploadedAt: isoAgo(VIDEO_AGES[i % VIDEO_AGES.length]), - durationSec: VIDEO_DURATIONS[i % VIDEO_DURATIONS.length], uploaderHandle: VIDEO_HANDLES[i % VIDEO_HANDLES.length], videoSrc: VIDEO_SOURCES[i % VIDEO_SOURCES.length], })); @@ -68,20 +79,20 @@ interface CellSeed { const SEEDS: CellSeed[] = [ { id: "A-14", label: "서면 A-14", district: "부산진구", center: { lat: 35.1573, lng: 129.0586 }, videoCount: 138, createdAt: "2026-07-10T09:00:00.000Z", durationSec: 24, location: "부산 부산진구 서면", recentAgo: 5 * MINUTE, fillRate: 73, viewCount: 1400, sampleSize: 5 }, - { id: "A-15", label: "전포 A-15", district: "부산진구", center: { lat: 35.1552, lng: 129.0633 }, videoCount: 72, createdAt: "2026-06-28T09:00:00.000Z", durationSec: 84, location: "부산 부산진구 전포", recentAgo: 2 * HOUR, fillRate: 61, viewCount: 8600, sampleSize: 4 }, + { id: "A-15", label: "전포 A-15", district: "부산진구", center: { lat: 35.1552, lng: 129.0633 }, videoCount: 72, createdAt: "2026-06-28T09:00:00.000Z", durationSec: 28, location: "부산 부산진구 전포", recentAgo: 2 * HOUR, fillRate: 61, viewCount: 8600, sampleSize: 4 }, { id: "B-07", label: "부전 B-07", district: "부산진구", center: { lat: 35.1631, lng: 129.0604 }, videoCount: 54, createdAt: "2026-07-05T09:00:00.000Z", location: "부산 부산진구 부전", recentAgo: 9 * HOUR, fillRate: 48, viewCount: 5200, sampleSize: 4 }, - { id: "B-08", label: "양정 B-08", district: "부산진구", center: { lat: 35.1699, lng: 129.0708 }, videoCount: 91, createdAt: "2026-07-14T09:00:00.000Z", durationSec: 132, location: "부산 부산진구 양정", recentAgo: 40 * MINUTE, fillRate: 67, viewCount: 12000, sampleSize: 5 }, - { id: "C-02", label: "광안리 C-02", district: "수영구", center: { lat: 35.1532, lng: 129.1187 }, videoCount: 205, createdAt: "2026-07-01T09:00:00.000Z", durationSec: 605, location: "부산 수영구 광안리", recentAgo: 12 * MINUTE, fillRate: 88, viewCount: 24000, sampleSize: 5 }, - { id: "C-03", label: "민락 C-03", district: "수영구", center: { lat: 35.1571, lng: 129.1214 }, videoCount: 47, createdAt: "2026-06-20T09:00:00.000Z", durationSec: 47, location: "부산 수영구 민락", recentAgo: 1 * DAY, fillRate: 39, viewCount: 3100, sampleSize: 3 }, - { id: "D-01", label: "대연 D-01", district: "남구", center: { lat: 35.1365, lng: 129.1005 }, videoCount: 119, createdAt: "2026-07-12T09:00:00.000Z", durationSec: 210, location: "부산 남구 대연", recentAgo: 33 * MINUTE, fillRate: 71, viewCount: 15400, sampleSize: 5 }, + { id: "B-08", label: "양정 B-08", district: "부산진구", center: { lat: 35.1699, lng: 129.0708 }, videoCount: 91, createdAt: "2026-07-14T09:00:00.000Z", durationSec: 22, location: "부산 부산진구 양정", recentAgo: 40 * MINUTE, fillRate: 67, viewCount: 12000, sampleSize: 5 }, + { id: "C-02", label: "광안리 C-02", district: "수영구", center: { lat: 35.1532, lng: 129.1187 }, videoCount: 205, createdAt: "2026-07-01T09:00:00.000Z", durationSec: 30, location: "부산 수영구 광안리", recentAgo: 12 * MINUTE, fillRate: 88, viewCount: 24000, sampleSize: 5 }, + { id: "C-03", label: "민락 C-03", district: "수영구", center: { lat: 35.1571, lng: 129.1214 }, videoCount: 47, createdAt: "2026-06-20T09:00:00.000Z", durationSec: 15, location: "부산 수영구 민락", recentAgo: 1 * DAY, fillRate: 39, viewCount: 3100, sampleSize: 3 }, + { id: "D-01", label: "대연 D-01", district: "남구", center: { lat: 35.1365, lng: 129.1005 }, videoCount: 119, createdAt: "2026-07-12T09:00:00.000Z", durationSec: 21, location: "부산 남구 대연", recentAgo: 33 * MINUTE, fillRate: 71, viewCount: 15400, sampleSize: 5 }, { id: "D-02", label: "용호 D-02", district: "남구", center: { lat: 35.1153, lng: 129.1123 }, videoCount: 33, createdAt: "2026-06-15T09:00:00.000Z", location: "부산 남구 용호", recentAgo: 3 * DAY, fillRate: 34, viewCount: 2200, sampleSize: 3 }, - { id: "E-05", label: "해운대 E-05", district: "해운대구", center: { lat: 35.1587, lng: 129.1604 }, videoCount: 176, createdAt: "2026-07-08T09:00:00.000Z", durationSec: 366, location: "부산 해운대구 해운대", recentAgo: 8 * MINUTE, fillRate: 82, viewCount: 31000, sampleSize: 5 }, - { id: "E-06", label: "센텀 E-06", district: "해운대구", center: { lat: 35.1691, lng: 129.1312 }, videoCount: 88, createdAt: "2026-06-30T09:00:00.000Z", durationSec: 59, location: "부산 해운대구 센텀", recentAgo: 4 * HOUR, fillRate: 59, viewCount: 7400, sampleSize: 4 }, - { id: "F-09", label: "온천장 F-09", district: "동래구", center: { lat: 35.2211, lng: 129.0866 }, videoCount: 64, createdAt: "2026-07-11T09:00:00.000Z", durationSec: 148, location: "부산 동래구 온천장", recentAgo: 55 * MINUTE, fillRate: 52, viewCount: 6100, sampleSize: 4 }, + { id: "E-05", label: "해운대 E-05", district: "해운대구", center: { lat: 35.1587, lng: 129.1604 }, videoCount: 176, createdAt: "2026-07-08T09:00:00.000Z", durationSec: 26, location: "부산 해운대구 해운대", recentAgo: 8 * MINUTE, fillRate: 82, viewCount: 31000, sampleSize: 5 }, + { id: "E-06", label: "센텀 E-06", district: "해운대구", center: { lat: 35.1691, lng: 129.1312 }, videoCount: 88, createdAt: "2026-06-30T09:00:00.000Z", durationSec: 19, location: "부산 해운대구 센텀", recentAgo: 4 * HOUR, fillRate: 59, viewCount: 7400, sampleSize: 4 }, + { id: "F-09", label: "온천장 F-09", district: "동래구", center: { lat: 35.2211, lng: 129.0866 }, videoCount: 64, createdAt: "2026-07-11T09:00:00.000Z", durationSec: 14, location: "부산 동래구 온천장", recentAgo: 55 * MINUTE, fillRate: 52, viewCount: 6100, sampleSize: 4 }, { id: "F-10", label: "명륜 F-10", district: "동래구", center: { lat: 35.213, lng: 129.0834 }, videoCount: 21, createdAt: "2026-06-25T09:00:00.000Z", location: "부산 동래구 명륜", recentAgo: 5 * DAY, fillRate: 27, viewCount: 1100, sampleSize: 2 }, - { id: "G-03", label: "초량 G-03", district: "동구", center: { lat: 35.1177, lng: 129.0394 }, videoCount: 97, createdAt: "2026-07-13T09:00:00.000Z", durationSec: 302, location: "부산 동구 초량", recentAgo: 18 * MINUTE, fillRate: 64, viewCount: 9800, sampleSize: 5 }, - { id: "G-04", label: "범일 G-04", district: "동구", center: { lat: 35.1368, lng: 129.0562 }, videoCount: 142, createdAt: "2026-07-03T09:00:00.000Z", durationSec: 75, location: "부산 동구 범일", recentAgo: 2 * HOUR, fillRate: 76, viewCount: 18700, sampleSize: 5 }, - { id: "H-11", label: "연산 H-11", district: "연제구", center: { lat: 35.1799, lng: 129.0796 }, videoCount: 58, createdAt: "2026-07-06T09:00:00.000Z", durationSec: 41, location: "부산 연제구 연산", recentAgo: 6 * HOUR, fillRate: 45, viewCount: 4600, sampleSize: 4 }, + { id: "G-03", label: "초량 G-03", district: "동구", center: { lat: 35.1177, lng: 129.0394 }, videoCount: 97, createdAt: "2026-07-13T09:00:00.000Z", durationSec: 29, location: "부산 동구 초량", recentAgo: 18 * MINUTE, fillRate: 64, viewCount: 9800, sampleSize: 5 }, + { id: "G-04", label: "범일 G-04", district: "동구", center: { lat: 35.1368, lng: 129.0562 }, videoCount: 142, createdAt: "2026-07-03T09:00:00.000Z", durationSec: 25, location: "부산 동구 범일", recentAgo: 2 * HOUR, fillRate: 76, viewCount: 18700, sampleSize: 5 }, + { id: "H-11", label: "연산 H-11", district: "연제구", center: { lat: 35.1799, lng: 129.0796 }, videoCount: 58, createdAt: "2026-07-06T09:00:00.000Z", durationSec: 11, location: "부산 연제구 연산", recentAgo: 6 * HOUR, fillRate: 45, viewCount: 4600, sampleSize: 4 }, { id: "H-12", label: "거제 H-12", district: "연제구", center: { lat: 35.1907, lng: 129.0745 }, videoCount: 12, createdAt: "2026-06-18T09:00:00.000Z", location: "부산 연제구 거제", recentAgo: 4 * DAY, fillRate: 19, viewCount: 640, sampleSize: 2 }, // videoCount === 0 격자 — 상세 선택 no-op·카드 비활성(AC 2·3) 검증용 { id: "I-01", label: "범천 I-01", district: "부산진구", center: { lat: 35.1461, lng: 129.0592 }, videoCount: 0, createdAt: "2026-07-02T09:00:00.000Z", location: "부산 부산진구 범천", recentAgo: 7 * DAY, fillRate: 0, viewCount: 0, sampleSize: 0 }, @@ -98,9 +109,10 @@ const SEEDS: CellSeed[] = [ * videoCount === 0인 격자(I-01)를 하나 포함해 상세 선택 no-op·카드 비활성(AC 2·3)을 검증한다. */ export const MOCK_CELLS: Cell[] = SEEDS.map( - ({ recentAgo, sampleSize, ...rest }) => ({ + ({ recentAgo, sampleSize, ...rest }, index) => ({ ...rest, recentUploadedAt: isoAgo(recentAgo), - videos: buildVideos(rest.id, sampleSize), + // videoId 블록: 격자 순번 (index+1)*100 — 격자 간 충돌 없는 결정적 번호 (MSG-289 추정 3) + videos: buildVideos((index + 1) * 100, sampleSize), }), ); diff --git a/apps/web/src/entities/dex/model/dex.ts b/apps/web/src/entities/dex/model/dex.ts index e7949287..cc47eac1 100644 --- a/apps/web/src/entities/dex/model/dex.ts +++ b/apps/web/src/entities/dex/model/dex.ts @@ -1,10 +1,28 @@ import type { LatLng } from "@/entities/cell"; +import type { + CollectionGridResponseDto, + CollectionSummaryResponseDto, + MyBadgeResponseDto, + RegionVideoResponseDto, +} from "@/shared/api/generated/types.gen"; + +/* + * MSG-289: 명세 대응 필드는 생성 타입(shared/api/generated)에서 type-only 파생한다. + * 응답 DTO는 전 필드 optional이라(백엔드 required 미명시) 화면 계약이 요구하는 필드를 + * Required로 승격한다 — 명세 필드명 변경·제거 시 Pick이 typecheck로 잡는다. + * 명세에 없는 화면 전용 필드는 FE 확장으로 분리해 교차(&)한다. + */ /** * 개인 도감 요약 — 백엔드가 제공하는 표시값 (MSG-121, 2026-07-22 개정 반영). * 지역명은 이 요약에 없다 — 현재 위치 역지오코딩이 소유한다(개정 D2, A5 개정). + * totalGridCount만 명세(CollectionSummaryResponseDto) 대응 — 나머지는 FE 확장: + * nickname·avatarSrc는 프로필 축(명세 부재), streakDays는 명세 전체 부재(백엔드 환류 후보), + * totalExploredPct는 대응 축 없음, badgeCount는 뱃지 목록 length로 유도 가능한 표시값. */ -export interface DexSummary { +export type DexSummary = Required< + Pick +> & { nickname: string; /** 아바타 이미지 URL — 없으면 이니셜 fallback 표시 */ avatarSrc?: string; @@ -15,64 +33,54 @@ export interface DexSummary { totalExploredPct: number; /** 연속 기록 일수 — "연속 스트릭" 통계 카드 표시값 (헤더 요약에서는 제거 — D1 dedup) */ streakDays: number; - /** 수집 격자 수 — 통계 카드 표시값 */ - collectedCellCount: number; /** 획득 뱃지 수 — 통계 카드 표시값 */ badgeCount: number; -} +}; -/** 사용자가 수집한 격자 — 최근 수집 목록·지도 오버레이의 단위 (MSG-121) */ -export interface CollectedCell { - /** 격자 id — entities/cell의 Cell.id와 같은 체계 */ - cellId: string; - /** 격자 라벨 (예: "서면 A-14") */ +/** + * 사용자가 수집한 격자 — 최근 수집 목록·지도 오버레이의 단위 (MSG-121). + * gridId·firstCollectedAt·videoCount는 명세(CollectionGridResponseDto) 대응 — + * gridId 값은 mock 체계 "A-14"를 유지한다(명세 "{gridY}_{gridX}" 전환은 후속, MSG-289 질문 5). + * label·district·center는 FE 확장 (regionName·gridY/gridX에서 파생 가능하나 형태가 달라 유지). + */ +export type CollectedCell = Required< + Pick +> & { + /** 격자 라벨 (예: "서면 A-14") — FE 확장 */ label: string; /** * 행정구(區) 이름 (예: "부산진구") — 갤러리 지역 매핑 키 (MSG-122 AC 1·4). - * Cell.district와 같은 체계이며 백엔드 제공 가정(mock은 MOCK_CELLS에서 동기화). + * Cell.district와 같은 체계 — FE 확장 (명세 regionName은 행정동 문자열이라 형태가 다름) */ district: string; - /** 격자 중심 좌표 — 행 클릭 시 지도 이동 목적지 (AC 16) */ + /** 격자 중심 좌표 — 행 클릭 시 지도 이동 목적지 (AC 16). FE 확장 (명세 gridY/gridX 디코드 파생은 후속) */ center: LatLng; - /** 수집 시각 (ISO 8601) — 최근 수집 목록 최신순 정렬 기준 (AC 14) */ - collectedAt: string; - /** 이 격자에서 수집(업로드)한 영상 수 — "영상 N개" 표시 (AC 15) */ - videoCount: number; -} +}; /** * 사용자가 수집(업로드)한 개별 영상 — 갤러리 탭 썸네일 그리드의 단위 (MSG-122). * 한 격자에 영상이 여러 개면 각각 별도 항목이다 (티켓 명시). + * videoId·gridId·thumbnailUrl·createdAt은 명세(RegionVideoResponseDto) 대응 — + * videoId는 소속 격자 Cell.videos(CellVideo.videoId)와 같은 체계로, 썸네일 클릭 시 + * 상세 시트의 활성 영상 매칭 키다 (AC 23). cellLabel은 FE 확장. */ -export interface CollectedVideo { - /** - * 영상 id — 소속 격자 Cell.videos(CellVideo.id)와 같은 체계 (예: "A-14-v1", ② B3). - * 썸네일 클릭 시 상세 시트의 활성 영상 매칭 키 (AC 23). 실 API가 Cell.videos에 없는 - * 영상을 내려주면 시트가 대표 영상(videos[0])으로 강등한다 (R8 — mock 전용 보장) - */ - id: string; - /** 소속 격자 id — CollectedCell.cellId와 같은 체계, 지역 필터 매칭 키 (AC 1) */ - cellId: string; - /** 격자 라벨 denormalize (예: "서면 A-14") — 썸네일 대체 텍스트용 (AC 10) */ - cellLabel: string; - /** 대표 프레임 썸네일 URL — 없으면 placeholder 타일 표시 (CellVideo.thumbnailSrc 관례, R1) */ - thumbnailSrc?: string; - /** 수집 시각 (ISO 8601) — 갤러리 최신 수집순 정렬 기준 (AC 1) */ - collectedAt: string; -} +export type CollectedVideo = Required< + Pick +> & + Pick & { + /** 격자 라벨 denormalize (예: "서면 A-14") — 썸네일 대체 텍스트용 (AC 10). FE 확장 */ + cellLabel: string; + }; /** * 뱃지 카탈로그 항목 — 뱃지 진열장의 단위 (MSG-123). * 획득 판정은 백엔드 소관(티켓 제외 범위) — 프론트는 earned를 표시만 한다. + * badgeId·name·earned 전부 명세(MyBadgeResponseDto) 대응 — 화면이 아직 안 쓰는 + * code·description·iconUrl 등은 필요해지는 티켓에서 추가한다. */ -export interface DexBadge { - /** 뱃지 id — 카탈로그 정의 순서와 함께 백엔드 제공 가정 */ - id: string; - /** 뱃지 이름 — 획득 시에만 라벨로 노출, 미획득은 "미획득" 라벨 (AC 4) */ - name: string; - /** 획득 여부 — 컬러/회색 원 구분 기준. mock에서는 고정값 */ - earned: boolean; -} +export type DexBadge = Required< + Pick +>; /** 도감 조회 응답 — queryKey ["dex"]의 반환 계약 */ export interface DexData { @@ -87,6 +95,8 @@ export interface DexData { /** * 지역(구)별 탐험률(%) 맵 (개정 D2) — 값 계산은 백엔드 소관 가정의 mock(A5 개정). * 키는 현재 위치 역지오코딩 결과(구 이름)와 매칭하며, 맵에 없는 지역은 0%로 처리한다(AC 21). + * 명세는 `RegionStatResponseDto[]`(배열, regionCode/행정동 키, progressRate)라 구조·키 + * 체계가 다르다 — 전환은 역지오코딩 매칭 로직과 함께 실연동 후속 티켓 (MSG-289 질문 6 승인). */ regionExploredPctMap: Record; } diff --git a/apps/web/src/entities/dex/model/mock-dex.test.ts b/apps/web/src/entities/dex/model/mock-dex.test.ts index f3a1c676..18f10e6a 100644 --- a/apps/web/src/entities/dex/model/mock-dex.test.ts +++ b/apps/web/src/entities/dex/model/mock-dex.test.ts @@ -15,28 +15,28 @@ describe("갤러리 mock 정합성 (AC 6)", () => { it("격자별 갤러리 영상 수가 CollectedCell.videoCount와 일치한다", () => { for (const cell of MOCK_DEX.collectedCells) { const count = MOCK_COLLECTED_VIDEOS.filter( - (v) => v.cellId === cell.cellId, + (v) => v.gridId === cell.gridId, ).length; - expect(count, `${cell.cellId} 영상 수`).toBe(cell.videoCount); + expect(count, `${cell.gridId} 영상 수`).toBe(cell.videoCount); } }); - it("모든 영상의 cellId가 수집 격자 목록에 존재한다", () => { + it("모든 영상의 gridId가 수집 격자 목록에 존재한다", () => { const collectedIds = new Set( - MOCK_DEX.collectedCells.map((c) => c.cellId), + MOCK_DEX.collectedCells.map((c) => c.gridId), ); for (const video of MOCK_COLLECTED_VIDEOS) { - expect(collectedIds.has(video.cellId), `${video.id}의 cellId`).toBe(true); + expect(collectedIds.has(video.gridId), `${video.videoId}의 gridId`).toBe(true); } }); - it("격자별 min(영상 collectedAt)이 격자 collectedAt과 같다 — 첫 영상 수집 = 격자 수집 (A9)", () => { + it("격자별 min(영상 createdAt)이 격자 firstCollectedAt과 같다 — 첫 영상 수집 = 격자 수집 (A9)", () => { for (const cell of MOCK_DEX.collectedCells) { const times = MOCK_COLLECTED_VIDEOS.filter( - (v) => v.cellId === cell.cellId, - ).map((v) => v.collectedAt); + (v) => v.gridId === cell.gridId, + ).map((v) => v.createdAt); const oldest = [...times].sort()[0]; - expect(oldest, `${cell.cellId} 최고령 영상 시각`).toBe(cell.collectedAt); + expect(oldest, `${cell.gridId} 최고령 영상 시각`).toBe(cell.firstCollectedAt); } }); @@ -44,36 +44,36 @@ describe("갤러리 mock 정합성 (AC 6)", () => { const busanjinCellIds = new Set( MOCK_DEX.collectedCells .filter((c) => c.district === "부산진구") - .map((c) => c.cellId), + .map((c) => c.gridId), ); const busanjinCount = MOCK_COLLECTED_VIDEOS.filter((v) => - busanjinCellIds.has(v.cellId), + busanjinCellIds.has(v.gridId), ).length; expect(busanjinCount).toBeGreaterThan(9); }); - it("모든 수집 영상 id가 소속 격자 Cell.videos의 id로 존재한다 — 상세 시트 활성 매칭 정합 (② B3, AC 6 추가)", () => { + it("모든 수집 영상 videoId가 소속 격자 Cell.videos의 videoId로 존재한다 — 상세 시트 활성 매칭 정합 (② B3, AC 6 추가)", () => { for (const video of MOCK_COLLECTED_VIDEOS) { - const cell = MOCK_CELLS.find((c) => c.id === video.cellId); + const cell = MOCK_CELLS.find((c) => c.id === video.gridId); expect( - cell?.videos.some((v) => v.id === video.id), - `${video.id}가 ${video.cellId}의 Cell.videos에 존재`, + cell?.videos.some((v) => v.videoId === video.videoId), + `${video.videoId}가 ${video.gridId}의 Cell.videos에 존재`, ).toBe(true); } }); it("썸네일 제공 항목과 미제공 항목이 모두 존재한다 — placeholder 경로 검증 가능 (A7)", () => { expect( - MOCK_COLLECTED_VIDEOS.some((v) => v.thumbnailSrc !== undefined), + MOCK_COLLECTED_VIDEOS.some((v) => v.thumbnailUrl !== undefined), ).toBe(true); expect( - MOCK_COLLECTED_VIDEOS.some((v) => v.thumbnailSrc === undefined), + MOCK_COLLECTED_VIDEOS.some((v) => v.thumbnailUrl === undefined), ).toBe(true); }); it("수집 격자마다 district가 있고 값은 MOCK_CELLS 체계(구 이름)를 따른다", () => { for (const cell of MOCK_DEX.collectedCells) { - expect(cell.district, `${cell.cellId}의 district`).toMatch(/구$/); + expect(cell.district, `${cell.gridId}의 district`).toMatch(/구$/); } }); diff --git a/apps/web/src/entities/dex/model/mock-dex.ts b/apps/web/src/entities/dex/model/mock-dex.ts index 1e7e1eca..45d83d17 100644 --- a/apps/web/src/entities/dex/model/mock-dex.ts +++ b/apps/web/src/entities/dex/model/mock-dex.ts @@ -1,4 +1,10 @@ import { MOCK_CELLS } from "@/entities/cell"; +import type { + CollectionGridResponseDto, + CollectionSummaryResponseDto, + MyBadgeResponseDto, + RegionVideoResponseDto, +} from "@/shared/api/generated/types.gen"; import type { CollectedCell, CollectedVideo, DexBadge, DexData } from "./dex"; const MINUTE = 60_000; @@ -15,13 +21,13 @@ const isoAgo = (ms: number) => new Date(Date.now() - ms).toISOString(); * A-14 4·B-08 5는 MSG-122 상향(A6) — 부산진구 합계(11)가 프리뷰 9개 제한을 넘겨 * "갤러리 전체 보기"를 시연할 수 있게 한다 (AC 6·13, R6). */ -const COLLECTED_SEEDS: { cellId: string; ago: number; videoCount: number }[] = [ - { cellId: "A-14", ago: 2 * HOUR, videoCount: 4 }, - { cellId: "B-08", ago: 5 * HOUR, videoCount: 5 }, - { cellId: "B-07", ago: 1 * DAY, videoCount: 1 }, - { cellId: "C-02", ago: 3 * DAY, videoCount: 4 }, - { cellId: "A-15", ago: 6 * DAY, videoCount: 1 }, - { cellId: "G-03", ago: 12 * DAY, videoCount: 2 }, +const COLLECTED_SEEDS: { gridId: string; ago: number; videoCount: number }[] = [ + { gridId: "A-14", ago: 2 * HOUR, videoCount: 4 }, + { gridId: "B-08", ago: 5 * HOUR, videoCount: 5 }, + { gridId: "B-07", ago: 1 * DAY, videoCount: 1 }, + { gridId: "C-02", ago: 3 * DAY, videoCount: 4 }, + { gridId: "A-15", ago: 6 * DAY, videoCount: 1 }, + { gridId: "G-03", ago: 12 * DAY, videoCount: 2 }, ]; const cellById = (id: string) => { @@ -30,16 +36,23 @@ const cellById = (id: string) => { return cell; }; +/** + * 명세 대응 필드(gridId·firstCollectedAt·videoCount)는 satisfies로 + * CollectionGridResponseDto에 연결한다 (MSG-289 AC 4). gridId 값은 mock 체계 "A-14" 유지 + * (명세 "{gridY}_{gridX}" 체계 전환은 후속 — 질문 5 승인). label·district·center는 FE 확장. + */ const MOCK_COLLECTED_CELLS: CollectedCell[] = COLLECTED_SEEDS.map( - ({ cellId, ago, videoCount }) => { - const { label, center, district } = cellById(cellId); + ({ gridId, ago, videoCount }) => { + const { label, center, district } = cellById(gridId); return { - cellId, + ...({ + gridId, + firstCollectedAt: isoAgo(ago), + videoCount, + } satisfies CollectionGridResponseDto), label, district, center, - collectedAt: isoAgo(ago), - videoCount, }; }, ); @@ -80,25 +93,29 @@ const VIDEO_GAP = 10 * MINUTE; /** * 격자별 수집 영상 mock (MSG-122) — 정합 불변식(AC 6): - * 격자당 videoCount개 생성, 첫 영상(i=0)의 collectedAt은 격자 collectedAt과 동일 + * 격자당 videoCount개 생성, 첫 영상(i=0)의 createdAt은 격자 firstCollectedAt과 동일 * ("첫 영상 수집 = 격자 수집", A9 — 갤러리 최신순과 최근 수집 목록 순서가 모순되지 않는다). - * 격자당 3번째 영상(i=2)은 thumbnailSrc 미제공 — placeholder 타일 경로 검증용 (A7·AC 10). - * id는 소속 Cell.videos(CellVideo)의 `-v` 체계와 일치(② B3) — "수집 영상 = 그 격자 영상의 - * 일부"의 mock 표현으로, 썸네일 클릭 → 상세 시트 활성 매칭 키가 된다. 전 수집 격자에서 - * videoCount ≤ sampleSize라 항상 매칭 가능하다 (AC 6 불변식, mock-dex.test). + * 격자당 3번째 영상(i=2)은 thumbnailUrl 미제공 — placeholder 타일 경로 검증용 (A7·AC 10). + * videoId는 소속 Cell.videos(CellVideo.videoId)에서 직접 가져온다(② B3) — "수집 영상 = + * 그 격자 영상의 일부"의 mock 표현으로, 썸네일 클릭 → 상세 시트 활성 매칭 키가 된다. + * 전 수집 격자에서 videoCount ≤ sampleSize라 항상 매칭 가능하다 (AC 6 불변식, mock-dex.test). + * 명세 대응 필드는 satisfies로 RegionVideoResponseDto에 연결한다 (MSG-289 AC 4) — cellLabel만 FE 확장. */ export const MOCK_COLLECTED_VIDEOS: CollectedVideo[] = - MOCK_COLLECTED_CELLS.flatMap((cell) => - Array.from({ length: cell.videoCount }, (_, i): CollectedVideo => ({ - id: `${cell.cellId}-v${i + 1}`, - cellId: cell.cellId, + MOCK_COLLECTED_CELLS.flatMap((cell) => { + const cellVideos = cellById(cell.gridId).videos; + return Array.from({ length: cell.videoCount }, (_, i): CollectedVideo => ({ + ...({ + videoId: cellVideos[i].videoId, + gridId: cell.gridId, + thumbnailUrl: i === 2 ? undefined : svgThumbnail(cell.label, i + 1), + createdAt: new Date( + Date.parse(cell.firstCollectedAt) + i * VIDEO_GAP, + ).toISOString(), + } satisfies RegionVideoResponseDto), cellLabel: cell.label, - collectedAt: new Date( - Date.parse(cell.collectedAt) + i * VIDEO_GAP, - ).toISOString(), - ...(i === 2 ? {} : { thumbnailSrc: svgThumbnail(cell.label, i + 1) }), - })), - ); + })); + }); /** * 지역(구)별 탐험률 mock 맵 (개정 D2, A5 개정) — 키는 MOCK_REGIONS(entities/region) 8개 구. @@ -123,38 +140,44 @@ const MOCK_REGION_EXPLORED_PCT: Record = { * 정의 순서 = 표시 순서(A2 — 획득 여부로 재정렬하지 않음, Figma도 컬러·회색 혼재 배치). * 획득 4개는 앞 8개(프리뷰 범위) 안에 배치해 프리뷰만 봐도 통계 카드 "획득 뱃지"와 * 정합이 눈으로 확인된다 (AC 5). 획득 판정은 백엔드 소관(제외 범위) — earned는 고정값. + * badgeId는 명세대로 number(정의 순서 1~12 — 기존 슬러그 문자열 id에서 전환, MSG-289 추정 3). + * satisfies로 MyBadgeResponseDto에 연결한다 (MSG-289 AC 4). */ export const MOCK_BADGES: DexBadge[] = [ - { id: "first-record", name: "첫 기록", earned: true }, - { id: "first-upload", name: "첫 업로드", earned: true }, - { id: "explorer", name: "탐험가", earned: true }, - { id: "busan-conquest", name: "부산 정복", earned: false }, - { id: "passion-recorder", name: "열정 기록러", earned: true }, - { id: "streak-30", name: "30일 스트릭", earned: false }, - { id: "seomyeon-master", name: "서면 마스터", earned: false }, - { id: "jeonpo-cafe", name: "전포 카페거리", earned: false }, - { id: "night-collector", name: "야간 수집가", earned: false }, - { id: "alley-explorer", name: "골목 탐험가", earned: false }, - { id: "gwangalli-trip", name: "광안리 원정", earned: false }, - { id: "cells-100", name: "격자 100칸", earned: false }, -]; + { badgeId: 1, name: "첫 기록", earned: true }, + { badgeId: 2, name: "첫 업로드", earned: true }, + { badgeId: 3, name: "탐험가", earned: true }, + { badgeId: 4, name: "부산 정복", earned: false }, + { badgeId: 5, name: "열정 기록러", earned: true }, + { badgeId: 6, name: "30일 스트릭", earned: false }, + { badgeId: 7, name: "서면 마스터", earned: false }, + { badgeId: 8, name: "전포 카페거리", earned: false }, + { badgeId: 9, name: "야간 수집가", earned: false }, + { badgeId: 10, name: "골목 탐험가", earned: false }, + { badgeId: 11, name: "광안리 원정", earned: false }, + { badgeId: 12, name: "격자 100칸", earned: false }, +] satisfies MyBadgeResponseDto[]; /** * 개인 도감 mock 데이터 (MSG-121, 2026-07-22 개정 반영). * Figma의 닉네임·68%·148개·12개·23일 등은 전부 플레이스홀더(티켓 [참고] 명시) — * 여기 값이 화면의 유일한 출처다. 실 API 전환 시 use-dex-query의 queryFn 내부만 교체한다. * totalExploredPct는 전체 지도 기준 미소값 0.012 (개정 D1, A12 — "전체 지도 0.012% 탐험"). - * collectedCellCount는 수집 목록 길이와, badgeCount는 카탈로그 earned 수와 일치시켜 + * totalGridCount는 수집 목록 길이와, badgeCount는 카탈로그 earned 수와 일치시켜 * mock의 자기모순을 피한다 (MSG-123 AC 5 — badgeCount는 백엔드 표시값 계약 유지, R3: * 파생은 mock 정합 장치일 뿐 프론트 획득 판정 로직이 아니다). * 수집 목록은 6건 — 최근 목록 상한 30(개정 D3)은 mock으로 발동하지 않으며 vitest가 판정한다(AC 14). + * summary는 명세 대응 필드(totalGridCount)만 satisfies로 CollectionSummaryResponseDto에 + * 연결한다 (MSG-289 AC 4 — 대응 필드 한정, 나머지는 FE 확장). */ export const MOCK_DEX: DexData = { summary: { + ...({ + totalGridCount: MOCK_COLLECTED_CELLS.length, + } satisfies CollectionSummaryResponseDto), nickname: "필맵퍼", totalExploredPct: 0.012, streakDays: 12, - collectedCellCount: MOCK_COLLECTED_CELLS.length, badgeCount: MOCK_BADGES.filter((b) => b.earned).length, }, collectedCells: MOCK_COLLECTED_CELLS, diff --git a/apps/web/src/entities/profile/model/mock-profile.ts b/apps/web/src/entities/profile/model/mock-profile.ts index 7be92fc2..6801be01 100644 --- a/apps/web/src/entities/profile/model/mock-profile.ts +++ b/apps/web/src/entities/profile/model/mock-profile.ts @@ -9,6 +9,12 @@ import type { ProfileData } from "./profile"; * 수집률 "부산 34%"는 Figma 값에서 지명만 치환(MVP 부산 서면 규칙, AC 6) — 도감의 * 전체 지도 탐험률·구 단위 탐험률과는 다른 제3의 축이라 정합 대상이 아니다 (R2). * 실 API 전환 시 use-profile-query의 queryFn 내부만 교체한다. + * + * [MSG-289] 명세 대응 스키마 부재 — satisfies 연결 없음 (추정 스키마를 발명하지 않는다). + * 프로필 조회(GET /users/me류) 엔드포인트 자체가 OpenAPI 명세에 없다: + * nickname·email·createdAt은 SignupResponseDto(가입 응답)에만 존재하고, streakDays는 + * 명세 전체 부재, collectionRate(시 단위)는 대응 축 없음, appVersion·locationEnabled는 + * 클라이언트 전용이다. 프로필 조회 API 신설은 백엔드 환류 후보 (스펙 리스크 6). */ export const MOCK_PROFILE: ProfileData = { nickname: MOCK_DEX.summary.nickname, diff --git a/apps/web/src/features/dex/model/badges.test.ts b/apps/web/src/features/dex/model/badges.test.ts index 89fd5ace..49867638 100644 --- a/apps/web/src/features/dex/model/badges.test.ts +++ b/apps/web/src/features/dex/model/badges.test.ts @@ -3,7 +3,7 @@ import type { DexBadge } from "@/entities/dex"; import { BADGE_PREVIEW_LIMIT, deriveBadgePreview } from "./badges"; const badge = (i: number, earned = false): DexBadge => ({ - id: `b-${String(i).padStart(2, "0")}`, + badgeId: i, name: `뱃지 ${i}`, earned, }); @@ -21,10 +21,10 @@ describe("deriveBadgePreview — 뱃지 프리뷰 파생 (MSG-123 AC 3·8)", () const preview = deriveBadgePreview(catalog(12)); expect(preview.badges.length).toBe(BADGE_PREVIEW_LIMIT); - expect(preview.badges.map((b) => b.id)).toEqual( + expect(preview.badges.map((b) => b.badgeId)).toEqual( catalog(12) .slice(0, 8) - .map((b) => b.id), + .map((b) => b.badgeId), ); expect(preview.hasMore).toBe(true); }); @@ -35,7 +35,7 @@ describe("deriveBadgePreview — 뱃지 프리뷰 파생 (MSG-123 AC 3·8)", () expect(exact.hasMore).toBe(false); const few = deriveBadgePreview(catalog(3)); - expect(few.badges.map((b) => b.id)).toEqual(["b-00", "b-01", "b-02"]); + expect(few.badges.map((b) => b.badgeId)).toEqual([0, 1, 2]); expect(few.hasMore).toBe(false); }); @@ -51,13 +51,13 @@ describe("deriveBadgePreview — 뱃지 프리뷰 파생 (MSG-123 AC 3·8)", () // 9번째(인덱스 9) 획득 뱃지는 프리뷰에 없다 — earned가 선별 기준이 아니다 const preview = deriveBadgePreview(catalog(12, [0, 9])); - expect(preview.badges.map((b) => b.id)).toEqual( + expect(preview.badges.map((b) => b.badgeId)).toEqual( catalog(12) .slice(0, 8) - .map((b) => b.id), + .map((b) => b.badgeId), ); - expect(preview.badges.filter((b) => b.earned).map((b) => b.id)).toEqual([ - "b-00", + expect(preview.badges.filter((b) => b.earned).map((b) => b.badgeId)).toEqual([ + 0, ]); }); @@ -66,6 +66,6 @@ describe("deriveBadgePreview — 뱃지 프리뷰 파생 (MSG-123 AC 3·8)", () deriveBadgePreview(input); expect(input.length).toBe(12); - expect(input.map((b) => b.id)).toEqual(catalog(12).map((b) => b.id)); + expect(input.map((b) => b.badgeId)).toEqual(catalog(12).map((b) => b.badgeId)); }); }); diff --git a/apps/web/src/features/dex/model/dex-summary.test.ts b/apps/web/src/features/dex/model/dex-summary.test.ts index 85f33a24..a038d57c 100644 --- a/apps/web/src/features/dex/model/dex-summary.test.ts +++ b/apps/web/src/features/dex/model/dex-summary.test.ts @@ -19,7 +19,7 @@ const summary = (overrides: Partial = {}): DexSummary => ({ nickname: "필맵퍼", totalExploredPct: 0.012, streakDays: 12, - collectedCellCount: 3, + totalGridCount: 3, badgeCount: 4, ...overrides, }); @@ -28,8 +28,8 @@ const REGION_PCT_MAP = { 부산진구: 52, 수영구: 34 }; /** 뱃지 카탈로그 픽스처 — 획득·미획득 혼재 (MSG-123 패스스루 판정용) */ const BADGES: DexBadge[] = [ - { id: "first-record", name: "첫 기록", earned: true }, - { id: "busan-conquest", name: "부산 정복", earned: false }, + { badgeId: 1, name: "첫 기록", earned: true }, + { badgeId: 4, name: "부산 정복", earned: false }, ]; const dexData = ( @@ -43,20 +43,20 @@ const dexData = ( }); const cell = ( - cellId: string, - collectedAt: string, + gridId: string, + firstCollectedAt: string, overrides: Partial = {}, ): CollectedCell => ({ - cellId, - label: `격자 ${cellId}`, + gridId, + label: `격자 ${gridId}`, district: "부산진구", center: { lat: 35.16, lng: 129.06 }, - collectedAt, + firstCollectedAt, videoCount: 1, ...overrides, }); -/** n개의 수집 격자를 생성 — 인덱스 i가 클수록 최신(collectedAt이 늦음) */ +/** n개의 수집 격자를 생성 — 인덱스 i가 클수록 최신(firstCollectedAt이 늦음) */ const manyCells = (n: number): CollectedCell[] => Array.from({ length: n }, (_, i) => cell( @@ -107,29 +107,29 @@ describe("formatExploredPct — 전체 진행률 포맷 (AC 20, 개정 D1·A12)" }); describe("sortByCollectedAtDesc — 최근 수집 최신순 정렬 (AC 14)", () => { - it("collectedAt 내림차순(최신순)으로 정렬한다", () => { + it("firstCollectedAt 내림차순(최신순)으로 정렬한다", () => { const cells = [ cell("A", "2026-07-19T09:00:00.000Z"), cell("B", "2026-07-21T09:00:00.000Z"), cell("C", "2026-07-20T09:00:00.000Z"), ]; - expect(sortByCollectedAtDesc(cells).map((c) => c.cellId)).toEqual([ + expect(sortByCollectedAtDesc(cells).map((c) => c.gridId)).toEqual([ "B", "C", "A", ]); }); - it("동률이면 cellId 오름차순으로 안정 정렬하고, 원본 배열은 변형하지 않는다", () => { + it("동률이면 gridId 오름차순으로 안정 정렬하고, 원본 배열은 변형하지 않는다", () => { const cells = [ cell("B", "2026-07-20T09:00:00.000Z"), cell("A", "2026-07-20T09:00:00.000Z"), ]; const sorted = sortByCollectedAtDesc(cells); - expect(sorted.map((c) => c.cellId)).toEqual(["A", "B"]); - expect(cells.map((c) => c.cellId)).toEqual(["B", "A"]); + expect(sorted.map((c) => c.gridId)).toEqual(["A", "B"]); + expect(cells.map((c) => c.gridId)).toEqual(["B", "A"]); }); }); @@ -143,15 +143,15 @@ describe("selectRecentCells — 최신순 정렬 + 상한 30 (AC 14 개정 D3)", const recent = selectRecentCells(cells); expect(recent.length).toBe(30); - expect(recent[0].cellId).toBe("C-30"); // 최신이 맨 앞 - expect(recent[29].cellId).toBe("C-01"); - expect(recent.some((c) => c.cellId === "C-00")).toBe(false); // 최고령 탈락 + expect(recent[0].gridId).toBe("C-30"); // 최신이 맨 앞 + expect(recent[29].gridId).toBe("C-01"); + expect(recent.some((c) => c.gridId === "C-00")).toBe(false); // 최고령 탈락 }); it("30개 이하 입력은 전체를 최신순으로 반환한다", () => { const recent = selectRecentCells(manyCells(3)); - expect(recent.map((c) => c.cellId)).toEqual(["C-02", "C-01", "C-00"]); + expect(recent.map((c) => c.gridId)).toEqual(["C-02", "C-01", "C-00"]); }); }); @@ -159,14 +159,14 @@ describe("excludeRemoved — 표시 목록에서 제거 항목 제외 (AC 23, it("removedIds에 있는 항목만 제외하고 나머지 순서를 유지한다", () => { const visible = excludeRemoved(selectRecentCells(manyCells(3)), ["C-01"]); - expect(visible.map((c) => c.cellId)).toEqual(["C-02", "C-00"]); + expect(visible.map((c) => c.gridId)).toEqual(["C-02", "C-00"]); }); it("정렬·상한 파생 이후에 적용된다 — 상위 30에서 1개 제거해도 31번째가 복귀하지 않는다", () => { const visible = excludeRemoved(selectRecentCells(manyCells(31)), ["C-30"]); expect(visible.length).toBe(29); - expect(visible.some((c) => c.cellId === "C-00")).toBe(false); + expect(visible.some((c) => c.gridId === "C-00")).toBe(false); }); it("빈 removedIds면 입력을 그대로 반환한다", () => { @@ -182,7 +182,7 @@ describe("deriveDexView — 도감 화면 파생 (AC 7·14·17·23)", () => { dexData([], { totalExploredPct: 0, streakDays: 0, - collectedCellCount: 0, + totalGridCount: 0, badgeCount: 0, }), ); @@ -198,7 +198,7 @@ describe("deriveDexView — 도감 화면 파생 (AC 7·14·17·23)", () => { const view = deriveDexView(dexData(manyCells(31))); expect(view.recentCells.length).toBe(30); - expect(view.recentCells[0].cellId).toBe("C-30"); + expect(view.recentCells[0].gridId).toBe("C-30"); }); it("파생 뷰는 지도 오버레이를 만들지 않는다 — 도감 500m 오버레이는 MSG-263 D8로 제거, 지도 표시는 셸 상시 층 소유", () => { @@ -233,7 +233,7 @@ describe("deriveDexView — 도감 화면 파생 (AC 7·14·17·23)", () => { }); it("통계 파생의 입력은 제거 상태와 무관하다 — 같은 입력이면 같은 결과다 (AC 23·24)", () => { - const data = dexData(manyCells(3), { collectedCellCount: 3 }); + const data = dexData(manyCells(3), { totalGridCount: 3 }); const before = deriveDexView(data); // 표시 목록만 excludeRemoved로 줄어들 뿐, 파생 입력(DexData)은 불변이다 diff --git a/apps/web/src/features/dex/model/dex-summary.ts b/apps/web/src/features/dex/model/dex-summary.ts index fe62c3a3..c17b8fff 100644 --- a/apps/web/src/features/dex/model/dex-summary.ts +++ b/apps/web/src/features/dex/model/dex-summary.ts @@ -24,16 +24,16 @@ export const formatExploredPct = (value: number): string => { }; /** - * 수집 격자를 collectedAt 내림차순(최신순)으로 정렬한다. [AC 14] - * 동률 시 cellId 오름차순 안정 정렬, 원본 배열은 변형하지 않는다(explore sortCells 패턴). + * 수집 격자를 firstCollectedAt 내림차순(최신순)으로 정렬한다. [AC 14] + * 동률 시 gridId 오름차순 안정 정렬, 원본 배열은 변형하지 않는다(explore sortCells 패턴). */ export const sortByCollectedAtDesc = ( cells: CollectedCell[], ): CollectedCell[] => [...cells].sort( (a, b) => - b.collectedAt.localeCompare(a.collectedAt) || - a.cellId.localeCompare(b.cellId), + b.firstCollectedAt.localeCompare(a.firstCollectedAt) || + a.gridId.localeCompare(b.gridId), ); /** 최근 수집 목록 상한 (개정 D3 — 티켓 명시값) */ @@ -51,7 +51,7 @@ export const selectRecentCells = (cells: CollectedCell[]): CollectedCell[] => export const excludeRemoved = ( cells: CollectedCell[], removedIds: string[], -): CollectedCell[] => cells.filter((c) => !removedIds.includes(c.cellId)); +): CollectedCell[] => cells.filter((c) => !removedIds.includes(c.gridId)); /** 도감 화면이 소비하는 파생 뷰 모델 — 요약(클램프 적용) + 최신순·상한 목록 */ export interface DexView { @@ -86,7 +86,7 @@ export const deriveDexView = ({ avatarSrc: summary.avatarSrc, totalExploredPct: clampPct(summary.totalExploredPct), streakDays: summary.streakDays, - collectedCellCount: summary.collectedCellCount, + collectedCellCount: summary.totalGridCount, badgeCount: summary.badgeCount, regionExploredPctMap, badges, diff --git a/apps/web/src/features/dex/model/gallery.test.ts b/apps/web/src/features/dex/model/gallery.test.ts index 7480ddcd..f92c3fd3 100644 --- a/apps/web/src/features/dex/model/gallery.test.ts +++ b/apps/web/src/features/dex/model/gallery.test.ts @@ -12,24 +12,24 @@ const cell = ( district: string, overrides: Partial = {}, ): CollectedCell => ({ - cellId, + gridId: cellId, label: `격자 ${cellId}`, district, center: { lat: 35.16, lng: 129.06 }, - collectedAt: "2026-07-01T09:00:00.000Z", + firstCollectedAt: "2026-07-01T09:00:00.000Z", videoCount: 1, ...overrides, }); const video = ( - id: string, - cellId: string, - collectedAt: string, + videoId: number, + gridId: string, + createdAt: string, ): CollectedVideo => ({ - id, - cellId, - cellLabel: `격자 ${cellId}`, - collectedAt, + videoId, + gridId, + cellLabel: `격자 ${gridId}`, + createdAt, }); const CELLS: CollectedCell[] = [ @@ -39,33 +39,33 @@ const CELLS: CollectedCell[] = [ ]; describe("selectRegionVideos — 지역 갤러리 영상 선별 (AC 1)", () => { - it("지정 지역 격자의 영상만 collectedAt 내림차순으로 반환하고, 다른 지역 영상은 포함하지 않는다", () => { + it("지정 지역 격자의 영상만 createdAt 내림차순으로 반환하고, 다른 지역 영상은 포함하지 않는다", () => { const videos = [ - video("v1", "A-14", "2026-07-20T09:00:00.000Z"), - video("v2", "C-02", "2026-07-22T09:00:00.000Z"), // 수영구 — 제외 대상 - video("v3", "B-08", "2026-07-21T09:00:00.000Z"), + video(1, "A-14", "2026-07-20T09:00:00.000Z"), + video(2, "C-02", "2026-07-22T09:00:00.000Z"), // 수영구 — 제외 대상 + video(3, "B-08", "2026-07-21T09:00:00.000Z"), ]; const result = selectRegionVideos(CELLS, videos, "부산진구"); - expect(result.map((v) => v.id)).toEqual(["v3", "v1"]); + expect(result.map((v) => v.videoId)).toEqual([3, 1]); }); - it("collectedAt 동률이면 id 오름차순으로 안정 정렬하고, 원본 배열은 변형하지 않는다", () => { + it("createdAt 동률이면 videoId 오름차순으로 안정 정렬하고, 원본 배열은 변형하지 않는다", () => { const same = "2026-07-20T09:00:00.000Z"; const videos = [ - video("v-b", "A-14", same), - video("v-a", "B-08", same), + video(2, "A-14", same), + video(1, "B-08", same), ]; const result = selectRegionVideos(CELLS, videos, "부산진구"); - expect(result.map((v) => v.id)).toEqual(["v-a", "v-b"]); - expect(videos.map((v) => v.id)).toEqual(["v-b", "v-a"]); + expect(result.map((v) => v.videoId)).toEqual([1, 2]); + expect(videos.map((v) => v.videoId)).toEqual([2, 1]); }); it("수집이 없는 지역은 빈 배열을 반환한다", () => { - const videos = [video("v1", "A-14", "2026-07-20T09:00:00.000Z")]; + const videos = [video(1, "A-14", "2026-07-20T09:00:00.000Z")]; expect(selectRegionVideos(CELLS, videos, "사상구")).toEqual([]); }); @@ -75,7 +75,7 @@ describe("deriveGalleryPreview — 프리뷰 9개 + hasMore 파생 (AC 2)", () = const manyVideos = (n: number): CollectedVideo[] => Array.from({ length: n }, (_, i) => video( - `v-${String(i).padStart(2, "0")}`, + i, "A-14", new Date(Date.UTC(2026, 6, 1) + i * 60_000).toISOString(), ), @@ -85,10 +85,10 @@ describe("deriveGalleryPreview — 프리뷰 9개 + hasMore 파생 (AC 2)", () = const preview = deriveGalleryPreview(manyVideos(10)); expect(preview.videos.length).toBe(GALLERY_PREVIEW_LIMIT); - expect(preview.videos.map((v) => v.id)).toEqual( + expect(preview.videos.map((v) => v.videoId)).toEqual( manyVideos(10) .slice(0, 9) - .map((v) => v.id), + .map((v) => v.videoId), ); expect(preview.hasMore).toBe(true); }); @@ -112,7 +112,7 @@ describe("deriveGalleryPreview — 프리뷰 9개 + hasMore 파생 (AC 2)", () = // 역지오코딩·디폴트 지역 폴백이 도달 불가한 죽은 코드가 되어 함수째 제거됐다 (B1·Q6). describe("districtOfCell — 격자 → 소속 지역 (AC 4)", () => { - it("cellId로 수집 격자의 소속 지역을 반환한다", () => { + it("gridId로 수집 격자의 소속 지역을 반환한다", () => { expect(districtOfCell(CELLS, "C-02")).toBe("수영구"); }); diff --git a/apps/web/src/features/dex/model/gallery.ts b/apps/web/src/features/dex/model/gallery.ts index f4b3ee75..c7e950a2 100644 --- a/apps/web/src/features/dex/model/gallery.ts +++ b/apps/web/src/features/dex/model/gallery.ts @@ -8,8 +8,8 @@ import type { CollectedCell, CollectedVideo } from "@/entities/dex"; */ /** - * 지정 지역(district) 격자의 영상만 collectedAt 내림차순으로 선별한다. [AC 1] - * 동률 시 id 오름차순 안정 정렬, 원본 배열은 변형하지 않는다(sortByCollectedAtDesc 패턴). + * 지정 지역(district) 격자의 영상만 createdAt 내림차순으로 선별한다. [AC 1] + * 동률 시 videoId 오름차순 안정 정렬, 원본 배열은 변형하지 않는다(sortByCollectedAtDesc 패턴). * mock queryFn(fetchGalleryVideos)이 이 함수로 "서버 지역 필터"를 흉내 낸다 (A2). */ export const selectRegionVideos = ( @@ -18,13 +18,13 @@ export const selectRegionVideos = ( region: string, ): CollectedVideo[] => { const regionCellIds = new Set( - cells.filter((c) => c.district === region).map((c) => c.cellId), + cells.filter((c) => c.district === region).map((c) => c.gridId), ); return videos - .filter((v) => regionCellIds.has(v.cellId)) + .filter((v) => regionCellIds.has(v.gridId)) .sort( (a, b) => - b.collectedAt.localeCompare(a.collectedAt) || a.id.localeCompare(b.id), + b.createdAt.localeCompare(a.createdAt) || a.videoId - b.videoId, ); }; @@ -50,10 +50,10 @@ export const deriveGalleryPreview = ( }); /** - * cellId → 소속 지역(district). 수집 목록에 없는 id는 null — 오버레이 셀 클릭 no-op 방어. [AC 4] + * gridId → 소속 지역(district). 수집 목록에 없는 id는 null — 오버레이 셀 클릭 no-op 방어. [AC 4] */ export const districtOfCell = ( cells: CollectedCell[], cellId: string, ): string | null => - cells.find((c) => c.cellId === cellId)?.district ?? null; + cells.find((c) => c.gridId === cellId)?.district ?? null; diff --git a/apps/web/src/features/dex/model/use-dex-query.test.ts b/apps/web/src/features/dex/model/use-dex-query.test.ts index d9d7531c..0f844430 100644 --- a/apps/web/src/features/dex/model/use-dex-query.test.ts +++ b/apps/web/src/features/dex/model/use-dex-query.test.ts @@ -17,7 +17,7 @@ describe("dex query (AC 19)", () => { nickname: expect.any(String), totalExploredPct: expect.any(Number), streakDays: expect.any(Number), - collectedCellCount: expect.any(Number), + totalGridCount: expect.any(Number), badgeCount: expect.any(Number), }); // 개정 D2 — 지역별 탐험률 맵 (디폴트 지역 "부산진구" 키 포함, A13) @@ -25,10 +25,10 @@ describe("dex query (AC 19)", () => { expect(data.collectedCells.length).toBeGreaterThan(0); for (const cell of data.collectedCells) { expect(cell).toMatchObject({ - cellId: expect.any(String), + gridId: expect.any(String), label: expect.any(String), center: { lat: expect.any(Number), lng: expect.any(Number) }, - collectedAt: expect.any(String), + firstCollectedAt: expect.any(String), videoCount: expect.any(Number), }); } diff --git a/apps/web/src/features/dex/model/use-gallery-query.test.ts b/apps/web/src/features/dex/model/use-gallery-query.test.ts index 133420fe..eed24261 100644 --- a/apps/web/src/features/dex/model/use-gallery-query.test.ts +++ b/apps/web/src/features/dex/model/use-gallery-query.test.ts @@ -16,14 +16,14 @@ describe("gallery query (AC 7·9, A2)", () => { expect(videos.length).toBeGreaterThan(9); // A6 — 프리뷰 제한 시연 가능 for (const video of videos) { expect(video).toMatchObject({ - id: expect.any(String), - cellId: expect.any(String), + videoId: expect.any(Number), + gridId: expect.any(String), cellLabel: expect.any(String), - collectedAt: expect.any(String), + createdAt: expect.any(String), }); } // 최신 수집순 (내림차순) - const times = videos.map((v) => v.collectedAt); + const times = videos.map((v) => v.createdAt); expect(times).toEqual([...times].sort().reverse()); }); diff --git a/apps/web/src/features/explore/model/cell-detail-store.test.ts b/apps/web/src/features/explore/model/cell-detail-store.test.ts index 9d4e5454..3d1f28b5 100644 --- a/apps/web/src/features/explore/model/cell-detail-store.test.ts +++ b/apps/web/src/features/explore/model/cell-detail-store.test.ts @@ -3,12 +3,12 @@ import type { Cell, CellVideo } from "@/entities/cell"; import { useCellDetailStore } from "./cell-detail-store"; import { useExploreFilterStore } from "./explore-filter-store"; -const video = (id: string): CellVideo => ({ - id, - title: `영상 ${id}`, +const video = (videoId: number): CellVideo => ({ + videoId, + title: `영상 ${videoId}`, viewCount: 100, - uploadedAt: "2026-07-20T00:00:00.000Z", - durationSec: 42, + recordedAt: "2026-07-20T00:00:00.000Z", + durationSec: 24, }); const makeCell = (id: string, videos: CellVideo[]): Cell => ({ @@ -25,8 +25,8 @@ const makeCell = (id: string, videos: CellVideo[]): Cell => ({ videos, }); -const cellA = makeCell("A", [video("A-v1"), video("A-v2"), video("A-v3")]); -const cellB = makeCell("B", [video("B-v1"), video("B-v2")]); +const cellA = makeCell("A", [video(11), video(12), video(13)]); +const cellB = makeCell("B", [video(21), video(22)]); const emptyCell = makeCell("Z", []); const state = () => useCellDetailStore.getState(); @@ -45,7 +45,7 @@ describe("cell-detail-store 선택/영상 액션", () => { it("격자를 선택하면 selectedCellId와 대표 영상(activeVideoId=videos[0])이 설정된다", () => { state().select(cellA); expect(state().selectedCellId).toBe("A"); - expect(state().activeVideoId).toBe("A-v1"); + expect(state().activeVideoId).toBe(11); }); it("close()는 선택을 해제한다 — selectedCellId=null (AC 4)", () => { @@ -62,22 +62,22 @@ describe("cell-detail-store 선택/영상 액션", () => { it("리스트 영상 클릭은 activeVideoId를 그 영상으로 바꾼다 (AC 17)", () => { state().select(cellA); - state().selectVideo("A-v3"); - expect(state().activeVideoId).toBe("A-v3"); + state().selectVideo(13); + expect(state().activeVideoId).toBe(13); }); it("다른 격자를 선택하면 activeVideoId가 새 격자의 대표 영상으로 초기화된다 (AC 20)", () => { state().select(cellA); - state().selectVideo("A-v3"); + state().selectVideo(13); state().select(cellB); - expect(state().activeVideoId).toBe("B-v1"); + expect(state().activeVideoId).toBe(21); }); it("이미 열린 같은 격자를 재선택하면 no-op이다 — 선택해둔 영상이 대표 영상으로 리셋되지 않는다", () => { state().select(cellA); - state().selectVideo("A-v3"); + state().selectVideo(13); state().select(cellA); - expect(state().activeVideoId).toBe("A-v3"); + expect(state().activeVideoId).toBe(13); expect(state().selectedCellId).toBe("A"); }); }); diff --git a/apps/web/src/features/explore/model/cell-detail-store.ts b/apps/web/src/features/explore/model/cell-detail-store.ts index 781b2852..08f527d2 100644 --- a/apps/web/src/features/explore/model/cell-detail-store.ts +++ b/apps/web/src/features/explore/model/cell-detail-store.ts @@ -5,11 +5,11 @@ interface CellDetailState { /** 상세 시트에 열린 격자 id — null이면 시트 닫힘 (목록만 표시) */ selectedCellId: string | null; /** 상단 대표 영상 영역에 표시 중인 영상 id — 선택 시 대표 영상(videos[0])으로 초기화 */ - activeVideoId: string | null; + activeVideoId: number | null; /** 격자를 선택해 상세 시트를 연다. videoCount === 0이면 no-op (AC 2). 다른 격자로 전환할 때만 activeVideoId를 대표 영상으로 리셋한다 (AC 20) — 이미 열린 같은 격자를 재클릭하면 no-op이라 선택해둔 영상이 유지된다. */ select: (cell: Cell) => void; /** 리스트 영상을 대표 영상 영역에 반영한다 — 실제 재생 트리거 없음 (AC 17). */ - selectVideo: (videoId: string) => void; + selectVideo: (videoId: number) => void; /** 상세 시트를 닫는다 — selectedCellId=null (AC 4). */ close: () => void; } @@ -26,7 +26,7 @@ export const useCellDetailStore = create((set) => ({ if (cell.videoCount === 0) return; set((state) => { if (state.selectedCellId === cell.id) return state; - return { selectedCellId: cell.id, activeVideoId: cell.videos[0]?.id ?? null }; + return { selectedCellId: cell.id, activeVideoId: cell.videos[0]?.videoId ?? null }; }); }, selectVideo: (videoId) => set({ activeVideoId: videoId }), diff --git a/apps/web/src/features/map-home/model/grid-overlay.test.ts b/apps/web/src/features/map-home/model/grid-overlay.test.ts index 8abfcf49..fe3a5608 100644 --- a/apps/web/src/features/map-home/model/grid-overlay.test.ts +++ b/apps/web/src/features/map-home/model/grid-overlay.test.ts @@ -27,8 +27,8 @@ const OPEN_SEA_VIEWPORT: Bounds = { ne: { lat: 34.55, lng: 129.06 }, }; -const OCCUPIED = MOCK_DEX.collectedCells.map(({ cellId, center }) => ({ - cellId, +const OCCUPIED = MOCK_DEX.collectedCells.map(({ gridId, center }) => ({ + gridId, center, })); @@ -101,10 +101,10 @@ describe("buildOccupiedGridCells — 점령 셀 격자 스냅 (MSG-263 AC 4·7, const overlays = buildOccupiedGridCells(OCCUPIED); expect(overlays.map((o) => o.id).sort()).toEqual( - OCCUPIED.map((c) => c.cellId).sort(), + OCCUPIED.map((c) => c.gridId).sort(), ); for (const cell of OCCUPIED) { - const overlay = overlays.find((o) => o.id === cell.cellId)!; + const overlay = overlays.find((o) => o.id === cell.gridId)!; expect(overlay.bounds).toEqual(cellBoundsAt(cellIndexAt(cell.center))); expect(overlay.occupied).toBe(true); } @@ -113,7 +113,7 @@ describe("buildOccupiedGridCells — 점령 셀 격자 스냅 (MSG-263 AC 4·7, it("셀 중심이 행정경계 밖인 셀은 점령 오버레이 대상이 아니다 (AC 4)", () => { const withSeaCell = [ ...OCCUPIED, - { cellId: "SEA-1", center: { lat: 34.95, lng: 129.0 } }, + { gridId: "SEA-1", center: { lat: 34.95, lng: 129.0 } }, ]; const overlays = buildOccupiedGridCells(withSeaCell); @@ -127,13 +127,13 @@ describe("excludeSectionCells — 상시 점령 셀 ∩ 섹션 게시 셀 1회 it("섹션 게시 셀과 id가 겹치는 상시 점령 셀은 렌더 대상에서 제외된다 — 교집합은 섹션(테마) 스타일로 1회만", () => { const sectionCells = [ - { id: OCCUPIED[0].cellId }, - { id: OCCUPIED[1].cellId }, + { id: OCCUPIED[0].gridId }, + { id: OCCUPIED[1].gridId }, ]; const visible = excludeSectionCells(persistent, sectionCells); - expect(visible.map((o) => o.id)).not.toContain(OCCUPIED[0].cellId); - expect(visible.map((o) => o.id)).not.toContain(OCCUPIED[1].cellId); + expect(visible.map((o) => o.id)).not.toContain(OCCUPIED[0].gridId); + expect(visible.map((o) => o.id)).not.toContain(OCCUPIED[1].gridId); expect(visible).toHaveLength(persistent.length - 2); }); diff --git a/apps/web/src/features/map-home/model/grid-overlay.ts b/apps/web/src/features/map-home/model/grid-overlay.ts index 90014a0b..1d941129 100644 --- a/apps/web/src/features/map-home/model/grid-overlay.ts +++ b/apps/web/src/features/map-home/model/grid-overlay.ts @@ -121,7 +121,7 @@ export const buildOccupiedGridCells = ( occupied .filter((cell) => isGridCellCenterInBusan(cell.center)) .map((cell) => ({ - id: cell.cellId, + id: cell.gridId, bounds: cellBoundsAt(cellIndexAt(cell.center)), occupied: true, })); diff --git a/apps/web/src/features/map-home/model/home-cell-detail.test.ts b/apps/web/src/features/map-home/model/home-cell-detail.test.ts index 28465d33..cf3e9124 100644 --- a/apps/web/src/features/map-home/model/home-cell-detail.test.ts +++ b/apps/web/src/features/map-home/model/home-cell-detail.test.ts @@ -41,11 +41,9 @@ describe("canOpenDetail — 셀 탭 → 상세 오픈 판정 (AC 9·10)", () => describe("myVideoIdsOf — 셀별 내 수집 영상 id (AC 9·10 — 내 영상 판별 키)", () => { it("A-14의 내 영상은 수집 영상 mock의 A-14 항목들이다 (A2 — 도감 수집 재사용)", () => { + // A-14는 MOCK_CELLS 첫 번째 격자 — videoId 블록 101~ (mock-cells 결정적 번호 체계) expect(myVideoIdsOf(MOCK_COLLECTED_VIDEOS, "A-14")).toEqual([ - "A-14-v1", - "A-14-v2", - "A-14-v3", - "A-14-v4", + 101, 102, 103, 104, ]); }); @@ -70,9 +68,9 @@ describe("deriveHomeCellDetail — 배지·영상 목록·버튼 파생 (AC 9·1 { id: "occupied", label: "내 점령" }, { id: "hot", label: "핫구역" }, ]); - expect(detail.myVideos.map((v) => v.id)).toEqual(myIds); - expect(detail.otherVideos.map((v) => v.id)).toEqual( - cell.videos.filter((v) => !myIds.includes(v.id)).map((v) => v.id), + expect(detail.myVideos.map((v) => v.videoId)).toEqual(myIds); + expect(detail.otherVideos.map((v) => v.videoId)).toEqual( + cell.videos.filter((v) => !myIds.includes(v.videoId)).map((v) => v.videoId), ); expect(detail.showMashup).toBe(true); }); @@ -102,8 +100,8 @@ describe("deriveHomeCellDetail — 배지·영상 목록·버튼 파생 (AC 9·1 expect(detail.badges).toEqual([{ id: "hot", label: "핫구역" }]); expect(detail.myVideos).toEqual([]); - expect(detail.otherVideos.map((v) => v.id)).toEqual( - cell.videos.map((v) => v.id), + expect(detail.otherVideos.map((v) => v.videoId)).toEqual( + cell.videos.map((v) => v.videoId), ); expect(detail.showMashup).toBe(true); }); @@ -119,7 +117,7 @@ describe("deriveHomeCellDetail — 배지·영상 목록·버튼 파생 (AC 9·1 }); expect(detail.badges).toEqual([{ id: "occupied", label: "내 점령" }]); - expect(detail.myVideos.map((v) => v.id)).toEqual(myIds); + expect(detail.myVideos.map((v) => v.videoId)).toEqual(myIds); expect(detail.otherVideos).toEqual([]); expect(detail.showMashup).toBe(false); }); @@ -155,16 +153,16 @@ describe("deriveHomeCellDetail — 배지·영상 목록·버튼 파생 (AC 9·1 }); // ── 서브타이틀 파생 (MSG-253 AC 6·7) ───────────────────────────────────────── -// mock 영상의 uploadedAt은 로드 시점 상대값이라 비결정적 — 고정 픽스처 + now 주입으로 단정한다. +// mock 영상의 recordedAt은 로드 시점 상대값이라 비결정적 — 고정 픽스처 + now 주입으로 단정한다. const NOW = new Date("2026-07-30T12:00:00.000Z"); -const fixtureVideo = (id: string, uploadedAt: string): CellVideo => ({ - id, +const fixtureVideo = (videoId: number, recordedAt: string): CellVideo => ({ + videoId, title: "표본 영상", viewCount: 10, - uploadedAt, - durationSec: 60, + recordedAt, + durationSec: 30, }); const fixtureCell = (videos: CellVideo[]): Cell => ({ @@ -182,18 +180,18 @@ const fixtureCell = (videos: CellVideo[]): Cell => ({ }); describe("deriveHomeCellDetail — 서브타이틀 파생 (MSG-253 AC 6·7)", () => { - it("비테마(내 점령) 상세: '내 영상 N개 · 마지막 업로드 M월 D일' — 날짜는 내 영상 중 최신 uploadedAt (AC 6)", () => { + it("비테마(내 점령) 상세: '내 영상 N개 · 마지막 업로드 M월 D일' — 날짜는 내 영상 중 최신 recordedAt (AC 6)", () => { // 최신(7/21)을 목록 중간에 둔다 — 첫/마지막 원소를 집는 구현을 걸러낸다 const cell = fixtureCell([ - fixtureVideo("T-01-v1", "2026-07-18T12:00:00.000Z"), - fixtureVideo("T-01-v2", "2026-07-21T12:00:00.000Z"), - fixtureVideo("T-01-v3", "2026-07-19T12:00:00.000Z"), + fixtureVideo(1, "2026-07-18T12:00:00.000Z"), + fixtureVideo(2, "2026-07-21T12:00:00.000Z"), + fixtureVideo(3, "2026-07-19T12:00:00.000Z"), ]); const detail = deriveHomeCellDetail({ cell, activeTheme: null, occupied: true, - myVideoIds: ["T-01-v1", "T-01-v2", "T-01-v3"], + myVideoIds: [1, 2, 3], now: NOW, }); @@ -202,7 +200,7 @@ describe("deriveHomeCellDetail — 서브타이틀 파생 (MSG-253 AC 6·7)", () it("내 영상이 0개면 날짜 부분 없이 '내 영상 0개'만 표기한다 (AC 6)", () => { const cell = fixtureCell([ - fixtureVideo("T-01-v1", "2026-07-18T12:00:00.000Z"), + fixtureVideo(1, "2026-07-18T12:00:00.000Z"), ]); const detail = deriveHomeCellDetail({ cell, @@ -218,15 +216,15 @@ describe("deriveHomeCellDetail — 서브타이틀 파생 (MSG-253 AC 6·7)", () it("핫구역 테마 상세: '내 영상 N개 · 최근 24시간 영상 +M개' — M은 셀 영상 중 24시간 내 업로드 수 (AC 7)", () => { // now 기준 -3h·-23h는 24시간 내, -30h는 밖 → +2개 const cell = fixtureCell([ - fixtureVideo("T-01-v1", "2026-07-30T09:00:00.000Z"), - fixtureVideo("T-01-v2", "2026-07-29T13:00:00.000Z"), - fixtureVideo("T-01-v3", "2026-07-29T06:00:00.000Z"), + fixtureVideo(1, "2026-07-30T09:00:00.000Z"), + fixtureVideo(2, "2026-07-29T13:00:00.000Z"), + fixtureVideo(3, "2026-07-29T06:00:00.000Z"), ]); const detail = deriveHomeCellDetail({ cell, activeTheme: "hot", occupied: true, - myVideoIds: ["T-01-v1"], + myVideoIds: [1], now: NOW, }); @@ -235,8 +233,8 @@ describe("deriveHomeCellDetail — 서브타이틀 파생 (MSG-253 AC 6·7)", () it("지역축제·팝업스토어 테마 상세도 같은 24시간 변형을 쓴다 (추정 1 — 테마 공통)", () => { const cell = fixtureCell([ - fixtureVideo("T-01-v1", "2026-07-30T09:00:00.000Z"), - fixtureVideo("T-01-v2", "2026-07-29T06:00:00.000Z"), + fixtureVideo(1, "2026-07-30T09:00:00.000Z"), + fixtureVideo(2, "2026-07-29T06:00:00.000Z"), ]); const base = { cell, occupied: false, myVideoIds: [], now: NOW }; @@ -279,7 +277,7 @@ describe("deriveHomeCellDetail — 위치 정보 블록 파생 (MSG-277 2차 AC it("위치 문자열은 cell.location 그대로, 마지막 업로드는 recentUploadedAt의 상대시간이다 — now 주입 결정성", () => { // fixtureCell.recentUploadedAt = 2026-07-30T00:00Z, NOW = 동일 일 12:00Z → 12시간 전 const cell = fixtureCell([ - fixtureVideo("T-01-v1", "2026-07-18T12:00:00.000Z"), + fixtureVideo(1, "2026-07-18T12:00:00.000Z"), ]); const detail = deriveHomeCellDetail({ cell, diff --git a/apps/web/src/features/map-home/model/home-cell-detail.ts b/apps/web/src/features/map-home/model/home-cell-detail.ts index 5dc43b9e..9fb4a479 100644 --- a/apps/web/src/features/map-home/model/home-cell-detail.ts +++ b/apps/web/src/features/map-home/model/home-cell-detail.ts @@ -26,12 +26,12 @@ export const canOpenDetail = ( return themeCellIds.includes(cellId); }; -/** 셀별 내 수집 영상 id 목록 — CollectedVideo.id는 Cell.videos(CellVideo.id)와 같은 체계 (A2) */ +/** 셀별 내 수집 영상 id 목록 — CollectedVideo.videoId는 Cell.videos(CellVideo.videoId)와 같은 체계 (A2) */ export const myVideoIdsOf = ( - collectedVideos: { id: string; cellId: string }[], + collectedVideos: { videoId: number; gridId: string }[], cellId: string, -): string[] => - collectedVideos.filter((v) => v.cellId === cellId).map((v) => v.id); +): number[] => + collectedVideos.filter((v) => v.gridId === cellId).map((v) => v.videoId); /** 상세 헤더 속성 배지 — id는 스타일 매핑 키(뷰), label은 표시 텍스트 (AC 9·10, MSG-277 route 포함) */ export interface HomeCellBadge { @@ -73,7 +73,7 @@ interface DeriveHomeCellDetailInput { /** 내 점령 셀 여부 — "내 점령" 배지·내 영상 섹션 기준 */ occupied: boolean; /** 이 셀에서 내가 수집한 영상 id (myVideoIdsOf 결과) */ - myVideoIds: string[]; + myVideoIds: number[]; /** 서브타이틀 "최근 24시간" 판정 기준 시각 — 테스트 결정성용 주입, 기본은 호출 시점 (AC 7) */ now?: Date; } @@ -90,15 +90,15 @@ const deriveSubtitle = ( const myPart = `내 영상 ${myVideos.length}개`; if (activeTheme) { const recent24hCount = cell.videos.filter( - (v) => now.getTime() - new Date(v.uploadedAt).getTime() < DAY_MS, + (v) => now.getTime() - new Date(v.recordedAt).getTime() < DAY_MS, ).length; return `${myPart} · 최근 24시간 영상 +${recent24hCount}개`; } if (myVideos.length === 0) return myPart; const latest = myVideos.reduce((a, b) => - new Date(a.uploadedAt).getTime() >= new Date(b.uploadedAt).getTime() ? a : b, + new Date(a.recordedAt).getTime() >= new Date(b.recordedAt).getTime() ? a : b, ); - return `${myPart} · 마지막 업로드 ${formatMonthDay(latest.uploadedAt)}`; + return `${myPart} · 마지막 업로드 ${formatMonthDay(latest.recordedAt)}`; }; /** @@ -121,7 +121,7 @@ export const deriveHomeCellDetail = ({ : []), ]; - const myVideos = cell.videos.filter((v) => myVideoIds.includes(v.id)); + const myVideos = cell.videos.filter((v) => myVideoIds.includes(v.videoId)); return { cellId: cell.id, @@ -130,7 +130,7 @@ export const deriveHomeCellDetail = ({ badges, myVideos, otherVideos: activeTheme - ? cell.videos.filter((v) => !myVideoIds.includes(v.id)) + ? cell.videos.filter((v) => !myVideoIds.includes(v.videoId)) : [], showMashup: activeTheme !== null, statsLine: `영상 ${cell.videoCount}개 · 조회 ${formatViewCountKo(cell.viewCount)} · 담수율 ${cell.fillRate}%`, diff --git a/apps/web/src/features/map-home/model/theme-feed.test.ts b/apps/web/src/features/map-home/model/theme-feed.test.ts index d80828dc..c294b8ae 100644 --- a/apps/web/src/features/map-home/model/theme-feed.test.ts +++ b/apps/web/src/features/map-home/model/theme-feed.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest"; import { MOCK_CELLS, type Cell, type CellVideo } from "@/entities/cell"; import { deriveThemeFeed } from "./theme-feed"; -// ── 고정 픽스처 — mock 영상의 uploadedAt은 로드 시점 상대값이라 비결정적, 정렬·mine 단정은 픽스처로 ── +// ── 고정 픽스처 — mock 영상의 recordedAt은 로드 시점 상대값이라 비결정적, 정렬·mine 단정은 픽스처로 ── -const fixtureVideo = (id: string, uploadedAt: string): CellVideo => ({ - id, +const fixtureVideo = (videoId: number, recordedAt: string): CellVideo => ({ + videoId, title: "표본 영상", viewCount: 100, - uploadedAt, - durationSec: 60, + recordedAt, + durationSec: 30, uploaderHandle: "@busan.vlog", }); @@ -55,46 +55,46 @@ describe("deriveThemeFeed — 테마 피드 파생 (AC 3·4)", () => { // 최신(7/29)을 목록 중간에 둔다 — 원본 순서를 그대로 두는 구현을 걸러낸다 const pool = hotPool({ "A-14": [ - fixtureVideo("A-14-v1", "2026-07-25T12:00:00.000Z"), - fixtureVideo("A-14-v2", "2026-07-29T12:00:00.000Z"), - fixtureVideo("A-14-v3", "2026-07-27T12:00:00.000Z"), + fixtureVideo(101, "2026-07-25T12:00:00.000Z"), + fixtureVideo(102, "2026-07-29T12:00:00.000Z"), + fixtureVideo(103, "2026-07-27T12:00:00.000Z"), ], }); const feed = deriveThemeFeed("hot", pool, []); - expect(feed.sections[0].videos.map((v) => v.id)).toEqual([ - "A-14-v2", - "A-14-v3", - "A-14-v1", + expect(feed.sections[0].videos.map((v) => v.videoId)).toEqual([ + 102, + 103, + 101, ]); }); it("수집 영상 id(myVideoIds)와 매칭되는 영상만 mine으로 표시된다 (AC 4)", () => { const pool = hotPool({ "A-14": [ - fixtureVideo("A-14-v1", "2026-07-29T12:00:00.000Z"), - fixtureVideo("A-14-v2", "2026-07-28T12:00:00.000Z"), + fixtureVideo(101, "2026-07-29T12:00:00.000Z"), + fixtureVideo(102, "2026-07-28T12:00:00.000Z"), ], }); - const feed = deriveThemeFeed("hot", pool, ["A-14-v2"]); + const feed = deriveThemeFeed("hot", pool, [102]); expect( - feed.sections[0].videos.map((v) => ({ id: v.id, mine: v.mine })), + feed.sections[0].videos.map((v) => ({ id: v.videoId, mine: v.mine })), ).toEqual([ - { id: "A-14-v1", mine: false }, - { id: "A-14-v2", mine: true }, + { id: 101, mine: false }, + { id: 102, mine: true }, ]); }); it("totalCount는 섹션 영상 수 합과 일치한다 — 셀 videoCount 필드가 아니라 실제 나열 표본 수 (AC 2·3, 추정 7)", () => { const pool = hotPool({ "A-14": [ - fixtureVideo("A-14-v1", "2026-07-29T12:00:00.000Z"), - fixtureVideo("A-14-v2", "2026-07-28T12:00:00.000Z"), + fixtureVideo(101, "2026-07-29T12:00:00.000Z"), + fixtureVideo(102, "2026-07-28T12:00:00.000Z"), ], - "B-07": [fixtureVideo("B-07-v1", "2026-07-27T12:00:00.000Z")], + "B-07": [fixtureVideo(301, "2026-07-27T12:00:00.000Z")], }); const feed = deriveThemeFeed("hot", pool, []); diff --git a/apps/web/src/features/map-home/model/theme-feed.ts b/apps/web/src/features/map-home/model/theme-feed.ts index f33bf305..ee1c64ce 100644 --- a/apps/web/src/features/map-home/model/theme-feed.ts +++ b/apps/web/src/features/map-home/model/theme-feed.ts @@ -35,7 +35,7 @@ export interface ThemeFeed { export const deriveThemeFeed = ( theme: ThemeId, cells: Cell[], - myVideoIds: string[], + myVideoIds: number[], ): ThemeFeed => { const sections = themeCellsOf(theme).flatMap(({ id }) => { const cell = cells.find((c) => c.id === id); @@ -47,10 +47,10 @@ export const deriveThemeFeed = ( videos: [...cell.videos] .sort( (a, b) => - new Date(b.uploadedAt).getTime() - - new Date(a.uploadedAt).getTime(), + new Date(b.recordedAt).getTime() - + new Date(a.recordedAt).getTime(), ) - .map((video) => ({ ...video, mine: myVideoIds.includes(video.id) })), + .map((video) => ({ ...video, mine: myVideoIds.includes(video.videoId) })), }, ]; }); diff --git a/apps/web/src/features/map-home/model/theme-overlay.test.ts b/apps/web/src/features/map-home/model/theme-overlay.test.ts index c6db77c0..5b6364e5 100644 --- a/apps/web/src/features/map-home/model/theme-overlay.test.ts +++ b/apps/web/src/features/map-home/model/theme-overlay.test.ts @@ -8,11 +8,11 @@ import { buildRouteOverlay, } from "./theme-overlay"; -const OCCUPIED = MOCK_DEX.collectedCells.map(({ cellId, center }) => ({ - cellId, +const OCCUPIED = MOCK_DEX.collectedCells.map(({ gridId, center }) => ({ + gridId, center, })); -const OCCUPIED_IDS = OCCUPIED.map((c) => c.cellId); +const OCCUPIED_IDS = OCCUPIED.map((c) => c.gridId); describe("buildHomeOverlayCells — 기본 상태 (AC 2, MSG-263 개정 2 D9)", () => { it("활성 테마가 없으면 아무것도 게시하지 않는다 — 점령 셀 표시는 셸 상시 층(MapShell) 소유다", () => { diff --git a/apps/web/src/features/map-home/model/theme-overlay.ts b/apps/web/src/features/map-home/model/theme-overlay.ts index b936e5d9..099e1c2b 100644 --- a/apps/web/src/features/map-home/model/theme-overlay.ts +++ b/apps/web/src/features/map-home/model/theme-overlay.ts @@ -36,7 +36,7 @@ export interface StyledCellOverlay extends CellOverlay { /** 내 점령 셀 입력 — CollectedCell(entities/dex)의 구조적 부분집합 */ export interface OccupiedCell { - cellId: string; + gridId: string; center: LatLng; } @@ -54,7 +54,7 @@ export const buildHomeOverlayCells = ( ): StyledCellOverlay[] => { if (activeTheme === null) return []; - const occupiedIds = new Set(occupiedCells.map((c) => c.cellId)); + const occupiedIds = new Set(occupiedCells.map((c) => c.gridId)); return themeCells .filter((c) => isGridCellCenterInBusan(c.center)) .map((c) => ({ diff --git a/apps/web/src/features/map-home/model/theme.test.ts b/apps/web/src/features/map-home/model/theme.test.ts index aede1f2b..c6413a5d 100644 --- a/apps/web/src/features/map-home/model/theme.test.ts +++ b/apps/web/src/features/map-home/model/theme.test.ts @@ -10,7 +10,7 @@ import { themeCellsOf, } from "./theme"; -const OCCUPIED_IDS = MOCK_DEX.collectedCells.map((c) => c.cellId); +const OCCUPIED_IDS = MOCK_DEX.collectedCells.map((c) => c.gridId); describe("테마 메타 — 칩 4개의 순서·라벨·색 (AC 1·3)", () => { it("칩은 핫구역 · 지역축제 · 팝업스토어 · 경로추천 순서다 (AC 1)", () => { diff --git a/apps/web/src/features/map-home/model/upload-hours.test.ts b/apps/web/src/features/map-home/model/upload-hours.test.ts index fc24f996..fce177e3 100644 --- a/apps/web/src/features/map-home/model/upload-hours.test.ts +++ b/apps/web/src/features/map-home/model/upload-hours.test.ts @@ -11,20 +11,20 @@ import { deriveUploadHourBuckets } from "./upload-hours"; const localIso = (hour: number, minute = 0): string => new Date(2026, 6, 30, hour, minute).toISOString(); -const videoAt = (id: string, iso: string): CellVideo => ({ - id, +const videoAt = (videoId: number, iso: string): CellVideo => ({ + videoId, title: "표본 영상", viewCount: 10, - uploadedAt: iso, - durationSec: 60, + recordedAt: iso, + durationSec: 30, }); describe("deriveUploadHourBuckets — 3시간 단위 8버킷 집계 (AC 9)", () => { it("각 영상은 자기 로컬 시각의 버킷에 정확히 1회 집계된다 — 2시→0–3시, 14시→12–15시, 23시→21–24시", () => { const buckets = deriveUploadHourBuckets([ - videoAt("v1", localIso(2)), - videoAt("v2", localIso(14)), - videoAt("v3", localIso(23)), + videoAt(1, localIso(2)), + videoAt(2, localIso(14)), + videoAt(3, localIso(23)), ]); expect(buckets).toEqual([ @@ -41,8 +41,8 @@ describe("deriveUploadHourBuckets — 3시간 단위 8버킷 집계 (AC 9)", () it("버킷 경계 정각은 시작 버킷에 속한다 — 3시 정각은 3–6시, 21시 정각은 21–24시", () => { const buckets = deriveUploadHourBuckets([ - videoAt("v1", localIso(3)), - videoAt("v2", localIso(21)), + videoAt(1, localIso(3)), + videoAt(2, localIso(21)), ]); expect(buckets.find((b) => b.startHour === 3)?.count).toBe(1); @@ -52,8 +52,8 @@ describe("deriveUploadHourBuckets — 3시간 단위 8버킷 집계 (AC 9)", () it("같은 버킷의 여러 영상은 누적된다 — 13시·13시 30분은 둘 다 12–15시", () => { const buckets = deriveUploadHourBuckets([ - videoAt("v1", localIso(13)), - videoAt("v2", localIso(13, 30)), + videoAt(1, localIso(13)), + videoAt(2, localIso(13, 30)), ]); expect(buckets.find((b) => b.startHour === 12)?.count).toBe(2); diff --git a/apps/web/src/features/map-home/model/upload-hours.ts b/apps/web/src/features/map-home/model/upload-hours.ts index 04e75d62..f86c3c6d 100644 --- a/apps/web/src/features/map-home/model/upload-hours.ts +++ b/apps/web/src/features/map-home/model/upload-hours.ts @@ -15,7 +15,7 @@ const BUCKET_HOURS = 3; const BUCKET_COUNT = 24 / BUCKET_HOURS; /** - * 영상들의 uploadedAt 로컬 시각을 3시간 단위 8버킷(0–3시 … 21–24시)으로 집계한다. + * 영상들의 recordedAt 로컬 시각을 3시간 단위 8버킷(0–3시 … 21–24시)으로 집계한다. * 각 영상은 자기 시각 버킷에 정확히 1회 — 버킷 count 합 = videos.length, 빈 표본이면 전 버킷 0. */ export const deriveUploadHourBuckets = ( @@ -26,7 +26,7 @@ export const deriveUploadHourBuckets = ( (_, i) => ({ startHour: i * BUCKET_HOURS, count: 0 }), ); for (const video of videos) { - const hour = new Date(video.uploadedAt).getHours(); + const hour = new Date(video.recordedAt).getHours(); buckets[Math.floor(hour / BUCKET_HOURS)].count += 1; } return buckets; diff --git a/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts b/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts index 2ee3a7b6..196d473e 100644 --- a/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts +++ b/apps/web/src/features/map-home/model/video-mini-panel-store.test.ts @@ -5,17 +5,17 @@ import { useThemeFilterStore } from "./theme-filter-store"; import { useVideoMiniPanelStore } from "./video-mini-panel-store"; /** 최소 영상 픽스처 — 서면 목 관례 (부산 서면 MVP) */ -const buildVideo = (id: string): CellVideo => ({ - id, - title: `표본 영상 ${id}`, +const buildVideo = (videoId: number): CellVideo => ({ + videoId, + title: `표본 영상 ${videoId}`, viewCount: 1200, - uploadedAt: "2026-07-29T12:00:00.000Z", - durationSec: 42, + recordedAt: "2026-07-29T12:00:00.000Z", + durationSec: 24, videoSrc: "https://mdn.github.io/shared-assets/videos/flower.mp4", }); -const VIDEO_A = buildVideo("A-14-v1"); -const VIDEO_B = buildVideo("A-14-v2"); +const VIDEO_A = buildVideo(101); +const VIDEO_B = buildVideo(102); const resetStores = () => { useVideoMiniPanelStore.setState( diff --git a/apps/web/src/pages/dex/DexPanel.tsx b/apps/web/src/pages/dex/DexPanel.tsx index 760e71ac..2eb13309 100644 --- a/apps/web/src/pages/dex/DexPanel.tsx +++ b/apps/web/src/pages/dex/DexPanel.tsx @@ -111,15 +111,15 @@ export const DexPanel = () => { [moveTo, enterGallery], ); - // 썸네일 클릭 → 그 격자 상세 시트 + 클릭한 영상 활성 (AC 23). 격자 소스에 없는 cellId는 + // 썸네일 클릭 → 그 격자 상세 시트 + 클릭한 영상 활성 (AC 23). 격자 소스에 없는 gridId는 // no-op(방어). 같은 격자 재클릭이면 select는 no-op이지만 selectVideo가 활성을 갱신한다 // (cell-detail-store 기존 계약 — 스펙 구현 계획 3) const handleVideoClick = useCallback( (video: CollectedVideo) => { - const cell = cells?.find((c) => c.id === video.cellId); + const cell = cells?.find((c) => c.id === video.gridId); if (!cell) return; selectDetailCell(cell); - selectDetailVideo(video.id); + selectDetailVideo(video.videoId); }, [cells, selectDetailCell, selectDetailVideo], ); @@ -271,7 +271,7 @@ const RecentCellList = ({ ) : (
    {cells.map((cell) => ( -
  • +
  • ({ - id: `badge-${i}`, + badgeId: i, name: `뱃지 ${i}`, earned: i === 0 || i === 4, })), diff --git a/apps/web/src/pages/dex/dex-panel.smoke.test.tsx b/apps/web/src/pages/dex/dex-panel.smoke.test.tsx index 08eb5f3d..917df0d8 100644 --- a/apps/web/src/pages/dex/dex-panel.smoke.test.tsx +++ b/apps/web/src/pages/dex/dex-panel.smoke.test.tsx @@ -74,7 +74,7 @@ const EMPTY_DEX: DexData = { nickname: "새 사용자", totalExploredPct: 0, streakDays: 0, - collectedCellCount: 0, + totalGridCount: 0, badgeCount: 0, }, collectedCells: [], @@ -85,22 +85,22 @@ const EMPTY_DEX: DexData = { /** 수집 2건 주입 데이터 — 오버레이 게시·행 클릭·X 제거 배선 확인용 */ const DEX_WITH_CELLS: DexData = { - summary: { ...EMPTY_DEX.summary, collectedCellCount: 2 }, + summary: { ...EMPTY_DEX.summary, totalGridCount: 2 }, collectedCells: [ { - cellId: "A-14", + gridId: "A-14", label: "서면 A-14", district: "부산진구", center: { lat: 35.1573, lng: 129.0586 }, - collectedAt: "2026-07-21T09:00:00.000Z", + firstCollectedAt: "2026-07-21T09:00:00.000Z", videoCount: 2, }, { - cellId: "B-07", + gridId: "B-07", label: "부전 B-07", district: "부산진구", center: { lat: 35.1631, lng: 129.0604 }, - collectedAt: "2026-07-20T09:00:00.000Z", + firstCollectedAt: "2026-07-20T09:00:00.000Z", videoCount: 1, }, ], diff --git a/apps/web/src/pages/dex/gallery-tab.smoke.test.tsx b/apps/web/src/pages/dex/gallery-tab.smoke.test.tsx index 69e831d3..d9933c2a 100644 --- a/apps/web/src/pages/dex/gallery-tab.smoke.test.tsx +++ b/apps/web/src/pages/dex/gallery-tab.smoke.test.tsx @@ -102,38 +102,38 @@ const DEX: DexData = { nickname: "필맵퍼", totalExploredPct: 0.012, streakDays: 3, - collectedCellCount: 2, + totalGridCount: 2, badgeCount: 1, }, collectedCells: [ { - cellId: "A-14", + gridId: "A-14", label: "서면 A-14", district: "부산진구", center: { lat: 35.1573, lng: 129.0586 }, - collectedAt: "2026-07-21T09:00:00.000Z", + firstCollectedAt: "2026-07-21T09:00:00.000Z", videoCount: 2, }, { - cellId: "C-02", + gridId: "C-02", label: "광안리 C-02", district: "수영구", center: { lat: 35.1532, lng: 129.1187 }, - collectedAt: "2026-07-19T09:00:00.000Z", + firstCollectedAt: "2026-07-19T09:00:00.000Z", videoCount: 1, }, ], // 뱃지 탭 경유 케이스(④ AC 22)가 있어 1건 제공 — badgeCount(1)와 earned 수 정합 유지 - badges: [{ id: "first-record", name: "첫 기록", earned: true }], + badges: [{ badgeId: 1, name: "첫 기록", earned: true }], regionExploredPctMap: { 부산진구: 22 }, }; -const cellVideo = (id: string, title: string): CellVideo => ({ - id, +const cellVideo = (videoId: number, title: string): CellVideo => ({ + videoId, title, viewCount: 100, - uploadedAt: "2026-07-20T00:00:00.000Z", - durationSec: 42, + recordedAt: "2026-07-20T00:00:00.000Z", + durationSec: 24, }); /** 탐색과 동일 소스(["cells"]) 주입 격자 — 썸네일 클릭 → 상세 시트 매칭 대상 (AC 23, Q7) */ @@ -150,9 +150,9 @@ const CELLS: Cell[] = [ fillRate: 88, viewCount: 24000, videos: [ - cellVideo("C-02-v1", "광안리 골목 브이로그"), - cellVideo("C-02-v2", "광안리 카페 투어"), - cellVideo("C-02-v3", "광안리 야경 산책"), + cellVideo(501, "광안리 골목 브이로그"), + cellVideo(502, "광안리 카페 투어"), + cellVideo(503, "광안리 야경 산책"), ], }, { @@ -166,7 +166,7 @@ const CELLS: Cell[] = [ recentUploadedAt: "2026-07-21T09:00:00.000Z", fillRate: 73, viewCount: 1400, - videos: [cellVideo("A-14-v1", "서면 거리 공연")], + videos: [cellVideo(101, "서면 거리 공연")], }, ]; @@ -176,54 +176,54 @@ const seedClient = (client: QueryClient) => { client.setQueryData(["cells"], CELLS); }; -/** 갤러리 주입 데이터 — 썸네일 제공 2건 + 미제공(placeholder) 1건 (AC 10, id는 -v 체계 B3) */ +/** 갤러리 주입 데이터 — 썸네일 제공 2건 + 미제공(placeholder) 1건 (AC 10, videoId는 Cell.videos 체계 B3) */ const GALLERY_VIDEOS: CollectedVideo[] = [ { - id: "A-14-v1", - cellId: "A-14", + videoId: 101, + gridId: "A-14", cellLabel: "서면 A-14", - thumbnailSrc: + thumbnailUrl: "data:image/svg+xml;utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'/%3E", - collectedAt: "2026-07-21T10:00:00.000Z", + createdAt: "2026-07-21T10:00:00.000Z", }, { - id: "A-14-v2", - cellId: "A-14", + videoId: 102, + gridId: "A-14", cellLabel: "서면 A-14", - thumbnailSrc: + thumbnailUrl: "data:image/svg+xml;utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'/%3E", - collectedAt: "2026-07-21T09:00:00.000Z", + createdAt: "2026-07-21T09:00:00.000Z", }, { - id: "B-07-v1", - cellId: "B-07", + videoId: 301, + gridId: "B-07", cellLabel: "부전 B-07", - collectedAt: "2026-07-20T09:00:00.000Z", + createdAt: "2026-07-20T09:00:00.000Z", }, ]; /** - * 수영구 갤러리 주입 데이터 — Cell.videos와 id 체계 일치(② B3, 활성 매칭) + - * 격자 소스에 없는 cellId 1건(Z-99 — AC 23 no-op 방어 판정용) + * 수영구 갤러리 주입 데이터 — Cell.videos와 videoId 체계 일치(② B3, 활성 매칭) + + * 격자 소스에 없는 gridId 1건(Z-99 — AC 23 no-op 방어 판정용) */ const SUYEONG_VIDEOS: CollectedVideo[] = [ { - id: "C-02-v1", - cellId: "C-02", + videoId: 501, + gridId: "C-02", cellLabel: "광안리 C-02", - collectedAt: "2026-07-21T10:00:00.000Z", + createdAt: "2026-07-21T10:00:00.000Z", }, { - id: "C-02-v2", - cellId: "C-02", + videoId: 502, + gridId: "C-02", cellLabel: "광안리 C-02", - collectedAt: "2026-07-21T09:00:00.000Z", + createdAt: "2026-07-21T09:00:00.000Z", }, { - id: "Z-99-v1", - cellId: "Z-99", + videoId: 901, + gridId: "Z-99", cellLabel: "유령 Z-99", - collectedAt: "2026-07-20T09:00:00.000Z", + createdAt: "2026-07-20T09:00:00.000Z", }, ]; @@ -309,7 +309,7 @@ describe("갤러리 뷰 스모크", () => { ).toBeTruthy(); }); - it("각 썸네일 타일이 격자 라벨 이름을 가진 button이고, thumbnailSrc 없는 항목은 placeholder 타일로 렌더된다 (AC 10 ② button화)", () => { + it("각 썸네일 타일이 격자 라벨 이름을 가진 button이고, thumbnailUrl 없는 항목은 placeholder 타일로 렌더된다 (AC 10 ② button화)", () => { const client = createClient(); seedClient(client); client.setQueryData(["dex", "gallery", "사상구"], GALLERY_VIDEOS); @@ -443,13 +443,13 @@ describe("갤러리 뷰 스모크", () => { fireEvent.click(screen.getByRole("button", { name: "유령 Z-99 수집 영상" })); expect(useCellDetailStore.getState().selectedCellId).toBeNull(); - // 두 번째 타일(C-02-v2) 클릭 — 대표 영상(videos[0])이 아닌 "클릭한 영상"이 활성이어야 한다 + // 두 번째 타일(videoId 502) 클릭 — 대표 영상(videos[0])이 아닌 "클릭한 영상"이 활성이어야 한다 fireEvent.click( screen.getAllByRole("button", { name: "광안리 C-02 수집 영상" })[1], ); expect(useCellDetailStore.getState().selectedCellId).toBe("C-02"); - expect(useCellDetailStore.getState().activeVideoId).toBe("C-02-v2"); + expect(useCellDetailStore.getState().activeVideoId).toBe(502); // 시트가 렌더된다 — 격자명 헤딩 + "이 격자의 영상" 리스트 (탐색 CellDetailSheet 동형, Q7) expect(screen.getByRole("heading", { name: "광안리 C-02" })).toBeTruthy(); expect(screen.getByText("이 격자의 영상")).toBeTruthy(); diff --git a/apps/web/src/pages/dex/ui/BadgeTabBody.tsx b/apps/web/src/pages/dex/ui/BadgeTabBody.tsx index 3f2eec10..39748efc 100644 --- a/apps/web/src/pages/dex/ui/BadgeTabBody.tsx +++ b/apps/web/src/pages/dex/ui/BadgeTabBody.tsx @@ -51,7 +51,7 @@ export const BadgeTabBody = ({ badges }: BadgeTabBodyProps) => {
    {shown.map((badge, i) => ( - + ))}
{showExpand && ( diff --git a/apps/web/src/pages/dex/ui/GalleryTabBody.tsx b/apps/web/src/pages/dex/ui/GalleryTabBody.tsx index 9ee3a3f7..2d82503d 100644 --- a/apps/web/src/pages/dex/ui/GalleryTabBody.tsx +++ b/apps/web/src/pages/dex/ui/GalleryTabBody.tsx @@ -79,7 +79,7 @@ const GalleryGrid = ({ <>