diff --git a/README.md b/README.md index 2db18917..056b8ab7 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,9 @@ Locus를 이해하는 가장 빠른 방법입니다. - 👉 [**실행 계획**](https://github.com/boostcampwm2025/web06-locus/wiki/%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D) → 일정, 마일스톤, 그리고 검증 전략 +- 👉 [**팀 공식 블로그**](https://locus-log.tistory.com/) + → 설계 결정 과정, 구현 중 고민, 회고를 기록합니다 + --- ## 🚀 시작하기 @@ -170,8 +173,8 @@ Locus를 이해하는 가장 빠른 방법입니다. pnpm exec playwright install ``` - > [!WARNING] - > 스토리북 테스트 실행을 위해 필수입니다. +> [!WARNING] +> 스토리북 테스트 실행을 위해 필수입니다. 4. **개발 서버 실행** diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 5c673edd..283fdd81 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -1,6 +1,3 @@ -// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from 'eslint-plugin-storybook'; - import base from '../../eslint.config.mjs'; import { defineConfig, globalIgnores } from 'eslint/config'; import globals from 'globals'; @@ -9,7 +6,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'; export default defineConfig( ...base, - globalIgnores(['dist/**', 'build/**']), + globalIgnores(['dist/**', 'build/**', '.storybook/**']), // 설정 파일들에 대한 타입 체크 (tsconfig.node.json 사용) { files: ['*.config.ts', 'tailwind.config.ts', 'vitest.shims.d.ts'], @@ -34,6 +31,7 @@ export default defineConfig( }, { files: ['**/*.{ts,tsx,js,jsx}'], + ignores: ['.storybook/**'], extends: [ reactHooks.configs['recommended-latest'], reactRefresh.configs.vite, @@ -42,7 +40,4 @@ export default defineConfig( globals: { ...globals.browser }, }, }, - // Storybook 파일에 대한 ESLint 설정 - globalIgnores(['!.storybook'], 'Include Storybook Directory'), - ...storybook.configs['flat/recommended'], ); diff --git a/apps/web/src/shared/icons/PinMarkerCompletedIcon.stories.tsx b/apps/web/src/shared/icons/PinMarkerCompletedIcon.stories.tsx new file mode 100644 index 00000000..f61bdc40 --- /dev/null +++ b/apps/web/src/shared/icons/PinMarkerCompletedIcon.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import PinMarkerCompletedIcon from './PinMarkerCompletedIcon'; + +const meta = { + title: 'Shared/Icons/PinMarkerCompletedIcon', + component: PinMarkerCompletedIcon, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + className: { + control: 'text', + description: 'Tailwind CSS classes for styling (size, color, etc.)', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const DifferentSizes: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/apps/web/src/shared/icons/PinMarkerCompletedIcon.tsx b/apps/web/src/shared/icons/PinMarkerCompletedIcon.tsx new file mode 100644 index 00000000..a248cb12 --- /dev/null +++ b/apps/web/src/shared/icons/PinMarkerCompletedIcon.tsx @@ -0,0 +1,57 @@ +import type { IconProps } from '../types'; + +export default function PinMarkerCompletedIcon({ + className, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/apps/web/src/shared/icons/PinMarkerPendingIcon.stories.tsx b/apps/web/src/shared/icons/PinMarkerPendingIcon.stories.tsx new file mode 100644 index 00000000..31bf826e --- /dev/null +++ b/apps/web/src/shared/icons/PinMarkerPendingIcon.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import PinMarkerPendingIcon from './PinMarkerPendingIcon'; + +const meta = { + title: 'Shared/Icons/PinMarkerPendingIcon', + component: PinMarkerPendingIcon, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + className: { + control: 'text', + description: 'Tailwind CSS classes for styling (size, color, etc.)', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const DifferentSizes: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/apps/web/src/shared/icons/PinMarkerPendingIcon.tsx b/apps/web/src/shared/icons/PinMarkerPendingIcon.tsx new file mode 100644 index 00000000..fac8dd66 --- /dev/null +++ b/apps/web/src/shared/icons/PinMarkerPendingIcon.tsx @@ -0,0 +1,83 @@ +import type { IconProps } from '../types'; + +export default function PinMarkerPendingIcon({ + className, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/apps/web/src/shared/icons/RefreshIcon.stories.tsx b/apps/web/src/shared/icons/RefreshIcon.stories.tsx index 88429d99..88dcf063 100644 --- a/apps/web/src/shared/icons/RefreshIcon.stories.tsx +++ b/apps/web/src/shared/icons/RefreshIcon.stories.tsx @@ -2,42 +2,44 @@ import type { Meta, StoryObj } from '@storybook/react'; import RefreshIcon from './RefreshIcon'; const meta = { - title: 'Shared/Icons/RefreshIcon', - component: RefreshIcon, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - className: { - control: 'text', - description: 'Tailwind CSS classes for styling (size, color, etc.)', - }, + title: 'Shared/Icons/RefreshIcon', + component: RefreshIcon, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + className: { + control: 'text', + description: 'Tailwind CSS classes for styling (size, color, etc.)', }, + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, + args: {}, }; export const WithDifferentColors: Story = { - render: () => ( -
- - - - -
- ), + render: () => ( +
+ + + + +
+ ), }; export const Interactive: Story = { - args: { - className: - 'w-8 h-8 text-blue-500 cursor-pointer hover:text-blue-700 transition-colors', - onClick: () => alert('Refresh clicked!'), + args: { + className: + 'w-8 h-8 text-blue-500 cursor-pointer hover:text-blue-700 transition-colors', + onClick: () => { + alert('Refresh clicked!'); }, + }, }; diff --git a/apps/web/src/shared/icons/index.ts b/apps/web/src/shared/icons/index.ts index 5716f22a..d2ac77bc 100644 --- a/apps/web/src/shared/icons/index.ts +++ b/apps/web/src/shared/icons/index.ts @@ -1,2 +1,4 @@ export { default as ErrorIcon } from './ErrorIcon'; export { default as RefreshIcon } from './RefreshIcon'; +export { default as PinMarkerPendingIcon } from './PinMarkerPendingIcon'; +export { default as PinMarkerCompletedIcon } from './PinMarkerCompletedIcon'; diff --git a/apps/web/src/shared/types/index.ts b/apps/web/src/shared/types/index.ts index b4d5216b..53839639 100644 --- a/apps/web/src/shared/types/index.ts +++ b/apps/web/src/shared/types/index.ts @@ -1,2 +1,8 @@ export type { IconProps } from './icon'; export type { LoadingPageProps, LoadingPageVersion } from './loading'; +export type { + Coordinates, + PinVariant, + PinMarkerData, + PinMarkerProps, +} from './marker'; diff --git a/apps/web/src/shared/types/marker.ts b/apps/web/src/shared/types/marker.ts new file mode 100644 index 00000000..2b98467a --- /dev/null +++ b/apps/web/src/shared/types/marker.ts @@ -0,0 +1,18 @@ +export interface Coordinates { + lat: number; + lng: number; +} + +export type PinVariant = 'record' | 'current'; + +export interface PinMarkerData { + id: string | number; + position: Coordinates; + variant: PinVariant; +} + +export interface PinMarkerProps { + pin: PinMarkerData; + isSelected?: boolean; + onClick?: (id: string | number) => void; +} diff --git a/apps/web/src/shared/ui/error/ErrorFallback.tsx b/apps/web/src/shared/ui/error/ErrorFallback.tsx index f95741e7..6aedc01c 100644 --- a/apps/web/src/shared/ui/error/ErrorFallback.tsx +++ b/apps/web/src/shared/ui/error/ErrorFallback.tsx @@ -2,48 +2,48 @@ import type { FallbackProps } from 'react-error-boundary'; import { ErrorIcon, RefreshIcon } from '../../icons'; function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { - return ( -
- {/* 배경 블러 */} -
+ return ( +
+ {/* 배경 블러 */} +
- {/* 오버레이 */} -
-
-
- -
+ {/* 오버레이 */} +
+
+
+ +
-

- 일시적인 문제가 발생했어요 -

-

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

+

+ 일시적인 문제가 발생했어요 +

+

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

- + - {/* 개발 환경에서만 에러 상세 정보 표시 */} - {import.meta.env.DEV && error && ( -
- - 에러 상세 정보 - -
-                                {error.toString()}
-                            
-
- )} -
-
+ {/* 개발 환경에서만 에러 상세 정보 표시 */} + {import.meta.env.DEV && error && ( +
+ + 에러 상세 정보 + +
+                {error instanceof Error ? error.message : String(error)}
+              
+
+ )}
- ); +
+
+ ); } export default ErrorFallback; diff --git a/apps/web/src/shared/ui/marker/PinMarker.stories.tsx b/apps/web/src/shared/ui/marker/PinMarker.stories.tsx new file mode 100644 index 00000000..48585a98 --- /dev/null +++ b/apps/web/src/shared/ui/marker/PinMarker.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import PinMarker from './PinMarker'; +import type { PinMarkerData } from '@/shared/types/marker'; + +const meta = { + title: 'Shared/UI/Marker/PinMarker', + component: PinMarker, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + pin: { + control: 'object', + description: '핀마커 데이터 (id, position, variant)', + }, + isSelected: { + control: 'boolean', + description: '선택 상태 (선택 애니메이션/스윕 표시)', + }, + onClick: { + action: 'pinClicked', + description: '핀 클릭 시 호출되는 콜백 (pin.id 전달)', + }, + }, + args: { + isSelected: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** 샘플 데이터 */ +const currentPin: PinMarkerData = { + id: 'current-1', + position: { lat: 37.5665, lng: 126.978 }, + variant: 'current', +}; + +const recordPin: PinMarkerData = { + id: 'record-1', + position: { lat: 37.5651, lng: 126.9895 }, + variant: 'record', +}; + +export const Interactive: Story = { + name: 'Interactive (부모 상태로 선택 관리)', + args: { + pin: currentPin, + }, + render: () => { + function InteractiveDemo() { + const [selectedId, setSelectedId] = useState( + null, + ); + + return ( +
+
+
+ Interactive Demo +
+
+ 핀을 클릭하면 선택 상태가 전환됩니다. +
+ {selectedId != null && ( +
+ 선택된 핀: {selectedId} +
+ )} +
+ +
+
+ { + setSelectedId(id); + }} + /> + current +
+ +
+ { + setSelectedId(id); + }} + /> + record +
+
+
+ ); + } + + return ; + }, + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + '실제 사용 패턴(부모에서 selectedId 관리)을 시연합니다. onClick으로 pin.id를 받아 선택 상태를 전환합니다.', + }, + }, + }, +}; + +/** + * 접근성 노트(현재 컴포넌트 기준) + * - role/button + tabIndex + aria-label + aria-pressed 제공 + */ +export const AccessibilityNotes: Story = { + name: 'Accessibility Notes', + args: { + pin: currentPin, + }, + render: () => ( +
+
+
접근성 메모
+
+ 현재 컴포넌트에 포함된 접근성 속성 요약입니다. +
+
+ +
    +
  • • role="button"
  • +
  • • tabIndex=0
  • +
  • • aria-label (핀마커 ID 포함)
  • +
  • • aria-pressed (선택 상태)
  • +
+ +
+ + +
+
+ ), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: '현재 제공하는 접근성 속성들을 요약합니다.', + }, + }, + }, +}; diff --git a/apps/web/src/shared/ui/marker/PinMarker.tsx b/apps/web/src/shared/ui/marker/PinMarker.tsx new file mode 100644 index 00000000..5336edd2 --- /dev/null +++ b/apps/web/src/shared/ui/marker/PinMarker.tsx @@ -0,0 +1,76 @@ +import { useMemo, useState } from 'react'; +import PinMarkerPendingIcon from '@/shared/icons/PinMarkerPendingIcon'; +import PinMarkerCompletedIcon from '@/shared/icons/PinMarkerCompletedIcon'; +import type { PinMarkerProps } from '@/shared/types/marker'; +import './marker-animations.css'; + +export default function PinMarker({ + pin, + isSelected = false, + onClick, +}: PinMarkerProps) { + const [isHovered, setIsHovered] = useState(false); + + const isCurrent = pin.variant === 'current'; + const isRecord = pin.variant === 'record'; + + const handleClick = () => onClick?.(pin.id); + + const rootClassName = useMemo(() => { + const classes = [ + 'pin-marker', + 'relative', + 'select-none', + 'cursor-pointer', + 'transition-transform', + 'duration-150', + ]; + + const isCurrentIdle = isCurrent && !isSelected; + const isRecordHovered = isRecord && !isSelected && isHovered; + const selectedClassName = isCurrent + ? 'pin-blue-selected' + : 'pin-purple-selected'; + + if (isCurrentIdle) classes.push('pin-blue-idle'); + if (isSelected) classes.push(selectedClassName); + + // 보라색 핀 hover 시 살짝 반응 (데스크톱 전용) + if (isRecordHovered) classes.push('pin-purple-hover'); + + return classes.join(' '); + }, [isCurrent, isRecord, isHovered, isSelected]); + + return ( +
{ + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + role="button" + tabIndex={0} + aria-label={`핀마커 ${String(pin.id)}`} + aria-pressed={isSelected} + > + {/* 아이콘 래퍼 */} +
+
+ {isCurrent ? ( + + ) : ( + + )} + + {/* 보라 선택 시 스윕 */} + {isRecord && isSelected && ( +
+
+
+ ); +} diff --git a/apps/web/src/shared/ui/marker/index.ts b/apps/web/src/shared/ui/marker/index.ts new file mode 100644 index 00000000..42395de7 --- /dev/null +++ b/apps/web/src/shared/ui/marker/index.ts @@ -0,0 +1 @@ +export { default as PinMarker } from './PinMarker'; diff --git a/apps/web/src/shared/ui/marker/marker-animations.css b/apps/web/src/shared/ui/marker/marker-animations.css new file mode 100644 index 00000000..e06eccde --- /dev/null +++ b/apps/web/src/shared/ui/marker/marker-animations.css @@ -0,0 +1,178 @@ +/* ========================================================================== + - transform 충돌 방지: lift(translateY)와 pulse(scale)를 서로 다른 래퍼에 적용 + - 파란 핀: idle에서도 무한 pulse+glow, selected에서도 위로 떠오른 채 pulse 유지 + - 보라 핀: selected 시 sweep + lift +============================================================================ */ + +/* 공통 */ + +/* 바깥 래퍼: lift(translateY) 담당 */ +.pin-motion-wrap { + position: relative; + transform: translateY(0); + transform-origin: 50% 100%; +} + +/* 안쪽 래퍼: pulse(scale) + glow(filter) 담당 */ +.pin-pulse-wrap { + position: relative; + transform-origin: 50% 100%; +} + +/* 아이콘 위 스윕 레이어가 잘 얹히도록 */ +.pin-pulse-wrap > svg { + display: block; +} + +/* lift 공통 키프레임 */ +@keyframes pinLift { + from { + transform: translateY(0); + } + to { + transform: translateY(-5px); + } +} + +/* ========================= + 파란 핀 (current) + - 기본: 계속 pulse + glow (더 티 나게) + - 선택: 위로 lift + pulse는 더 크게 유지 + 그림자 확산 +========================= */ + +/* 기본 상태: 작은 pulse+glow */ +.pin-blue-pulse { + animation: bluePulseGlowIdle 1.6s ease-in-out infinite; +} + +/* 선택 상태: 더 큰 pulse+glow */ +.pin-blue-selected .pin-blue-pulse { + animation: bluePulseGlowSelected 1.6s ease-in-out infinite; +} + +/* 기본 상태 애니메이션: 작게 */ +@keyframes bluePulseGlowIdle { + 0% { + transform: scale(1); + filter: drop-shadow(0 0 0 rgba(59, 130, 246, 0)) + drop-shadow(0 0 0 rgba(59, 130, 246, 0)); + } + 45% { + transform: scale(1.02); /* 1.05 -> 1.02로 줄임 */ + filter: drop-shadow(0 6px 16px rgba(59, 130, 246, 0.25)) + drop-shadow(0 0 16px rgba(59, 130, 246, 0.15)); + } + 100% { + transform: scale(1); + filter: drop-shadow(0 0 0 rgba(59, 130, 246, 0)) + drop-shadow(0 0 0 rgba(59, 130, 246, 0)); + } +} + +/* 선택 상태 애니메이션: 크게 (기존 유지 또는 더 크게) */ +@keyframes bluePulseGlowSelected { + 0% { + transform: scale(1); + filter: drop-shadow(0 0 0 rgba(59, 130, 246, 0)) + drop-shadow(0 0 0 rgba(59, 130, 246, 0)); + } + 45% { + transform: scale(1.08); /* 1.05 -> 1.08로 키움 (또는 1.1) */ + filter: drop-shadow(0 12px 26px rgba(59, 130, 246, 0.42)) + drop-shadow(0 0 26px rgba(59, 130, 246, 0.3)); + } + 100% { + transform: scale(1); + filter: drop-shadow(0 0 0 rgba(59, 130, 246, 0)) + drop-shadow(0 0 0 rgba(59, 130, 246, 0)); + } +} + +/* 파란 선택: 위로 올라가 정착(바깥 래퍼에 적용) */ +.pin-blue-selected .pin-motion-wrap { + animation: pinLift 180ms ease-in-out 1; + animation-fill-mode: forwards; /* 올라간 상태 유지 */ +} + +/* 파란 핀 선택 시 + 표시 pulse 애니메이션 */ +.pin-blue-selected .pin-plus-icon { + animation: plusIconPulse 1.6s ease-in-out infinite; +} + +@keyframes plusIconPulse { + 0% { + transform: scale(1.1); + } + 45% { + transform: scale(1.2); + } + 100% { + transform: scale(1.1); + } +} + +/* ========================= + 보라 핀 (record) + - 기본: 정적 + - 선택: lift + scale-up + sweep(1회) +========================= */ + +/* 선택 시 위로 lift (바깥 래퍼: translateY 담당) */ +.pin-purple-selected .pin-motion-wrap { + animation: pinLift 180ms ease-in-out 1; + animation-fill-mode: forwards; +} + +/* 보라 선택 시 살짝 커짐 (안쪽 래퍼: scale 담당) */ +.pin-pulse-wrap { + transform: scale(1); + transform-origin: 50% 100%; + transition: transform 160ms ease; /* 선택/해제 모두 부드럽게 */ +} + +.pin-purple-selected .pin-pulse-wrap { + transform: scale(1.06); +} + +/* 하이라이트 스윕 (선택 시에만 span이 렌더된다는 전제) */ +.pin-sweep { + position: absolute; + inset: 0; + pointer-events: none; + + /* 대각선으로 스쳐가는 얇은 하이라이트 */ + background: linear-gradient( + 120deg, + rgba(255, 255, 255, 0) 35%, + rgba(255, 255, 255, 0.55) 50%, + rgba(255, 255, 255, 0) 65% + ); + + /* 시작 위치 */ + transform: translateX(-60%); + opacity: 0; + + /* 한 번만 실행 */ + animation: sweepOnce 600ms ease-in-out 1; + + mix-blend-mode: screen; + z-index: 1; /* 아이콘 위로 */ +} + +/* 스윕: 끊김 방지 opacity 사용 */ +@keyframes sweepOnce { + 0% { + transform: translateX(-60%); + opacity: 0; + } + 10% { + opacity: 0.9; + } + 90% { + opacity: 0.9; + } + 100% { + transform: translateX(60%); + opacity: 0; + } +}