Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
→ 설계 결정 과정, 구현 중 고민, 회고를 기록합니다

---

## 🚀 시작하기
Expand Down Expand Up @@ -170,8 +173,8 @@ Locus를 이해하는 가장 빠른 방법입니다.
pnpm exec playwright install
```

> [!WARNING]
> 스토리북 테스트 실행을 위해 필수입니다.
> [!WARNING]
> 스토리북 테스트 실행을 위해 필수입니다.

4. **개발 서버 실행**

Expand Down
9 changes: 2 additions & 7 deletions apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'],
Expand All @@ -34,6 +31,7 @@ export default defineConfig(
},
{
files: ['**/*.{ts,tsx,js,jsx}'],
ignores: ['.storybook/**'],
extends: [
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
Expand All @@ -42,7 +40,4 @@ export default defineConfig(
globals: { ...globals.browser },
},
},
// Storybook 파일에 대한 ESLint 설정
globalIgnores(['!.storybook'], 'Include Storybook Directory'),
...storybook.configs['flat/recommended'],
);
34 changes: 34 additions & 0 deletions apps/web/src/shared/icons/PinMarkerCompletedIcon.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof PinMarkerCompletedIcon>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {},
};

export const DifferentSizes: Story = {
render: () => (
<div className="flex gap-4 items-end">
<PinMarkerCompletedIcon className="w-14 h-20" />
<PinMarkerCompletedIcon className="w-28 h-40" />
<PinMarkerCompletedIcon className="w-42 h-60" />
</div>
),
};
57 changes: 57 additions & 0 deletions apps/web/src/shared/icons/PinMarkerCompletedIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { IconProps } from '../types';

export default function PinMarkerCompletedIcon({
className,
...props
}: IconProps) {
return (
<svg
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 svg 설정을 svgr 같은 것으로 처리하지 않는 이유가 궁금해요!!
Vite 에서 지원해주는 vite-plugin-svgr 이런걸 쓰면 최적화도 자동으로 해준다고 본 거 같아서요!!
(번들 크기 최소화, tree-shaking 등)

Copy link
Collaborator Author

@mindaaaa mindaaaa Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고민을 해보긴 했는데,
개발 편의와 운영 측면에서 svg를 컴포넌트화하는게 더 편해서 이쪽으로 진행하고 있어요!

  • src 아래 자산은 어차피 번들링 대상이라 svgr을 도입한다고 해서 성능/최적화가 결정적으로 달라진다고 보진 않았고,
  • 현재 단계에서는 관리할 SVG 파일이 많지 않고 점진적으로 늘어나는 구조라, 초기 셋업(플러그인/규칙/컨벤션) 비용을 먼저 지는 것보다 필요해졌을 때 도입하는 편이 더 합리적이라고 판단했습니다!

특히 제가 아직 svgr에 익숙하지 못한 탓도 있겠지만,
관리할 아이콘들이 상태별로 내부 요소(ex. 색상 변경)를 제어하는 경우가 많은데.

svgr 같은 경우는 currentColor 규칙에 맞춰 fill/stroke를 정리하거나 변환 옵션을 관리해서 파일을 붙여야,

<PinIcon
  className={cn(
    'w-6 h-6',
    isSelected && 'text-blue-600'
  )}
  data-selected={isSelected}
/>

이런 식의 제어가 가능해지는데.

특정 path만 class를 붙이고 싶어지면 또 다시 svgr 결과물을 내부적으로 편집하고...
요런게 직접 svg를 컴포넌트화했을 때보다 관리가 불편하다고 생각합니다!

return (
  <svg className={iconClass}>
    <g className={isSelected ? 'icon-plus-selected' : 'icon-plus'}>
    <g className="icon-body" />
  </svg>
);

이게 더 보기 편한 느낌이라, 현재는 이렇게 진행하고 있습니다!
추후 아이콘 에셋이 대량으로 늘면 svgr을 도입해도 좋을 것 같아요.

숙련도 부족 이슈 ㅠㅠ

{...props}
className={className}
xmlns="http://www.w3.org/2000/svg"
width="56"
height="79"
viewBox="0 0 56 79"
fill="none"
aria-hidden="true"
>
<path
opacity="0.2"
d="M28 75.5985C32.6391 75.5985 36.3998 74.3449 36.3998 72.7985C36.3998 71.2521 32.6391 69.9986 28 69.9986C23.3609 69.9986 19.6002 71.2521 19.6002 72.7985C19.6002 74.3449 23.3609 75.5985 28 75.5985Z"
fill="black"
/>
<path
d="M28 44.7991V69.9986"
stroke="#8B5CF6"
strokeWidth="3.49993"
strokeLinecap="round"
/>
<path
d="M28 5.59988C39.1998 5.59988 44.7997 13.9997 44.7997 25.1995C44.7997 36.3993 28 44.7991 28 44.7991C28 44.7991 11.2003 36.3993 11.2003 25.1995C11.2003 13.9997 16.8002 5.59988 28 5.59988Z"
fill="#A78BFA"
stroke="#7C3AED"
strokeWidth="2.79994"
/>
<path
opacity="0.9"
d="M28 30.7994C32.6391 30.7994 36.3998 27.0386 36.3998 22.3995C36.3998 17.7604 32.6391 13.9997 28 13.9997C23.3609 13.9997 19.6002 17.7604 19.6002 22.3995C19.6002 27.0386 23.3609 30.7994 28 30.7994Z"
fill="white"
/>
<path
d="M25.2001 25.1995V16.7997L32.1999 19.5996L25.2001 22.3995V25.1995Z"
fill="#7C3AED"
/>
<path
d="M25.2001 16.7997V27.9994"
stroke="#7C3AED"
strokeWidth="2.09996"
strokeLinecap="round"
/>
<path
opacity="0.3"
d="M22.4001 18.1996C25.4928 18.1996 28 16.3193 28 13.9997C28 11.6802 25.4928 9.7998 22.4001 9.7998C19.3074 9.7998 16.8002 11.6802 16.8002 13.9997C16.8002 16.3193 19.3074 18.1996 22.4001 18.1996Z"
fill="white"
/>
</svg>
);
}
34 changes: 34 additions & 0 deletions apps/web/src/shared/icons/PinMarkerPendingIcon.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof PinMarkerPendingIcon>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {},
};

export const DifferentSizes: Story = {
render: () => (
<div className="flex gap-4 items-end">
<PinMarkerPendingIcon className="w-14 h-20" />
<PinMarkerPendingIcon className="w-28 h-40" />
<PinMarkerPendingIcon className="w-42 h-60" />
</div>
),
};
83 changes: 83 additions & 0 deletions apps/web/src/shared/icons/PinMarkerPendingIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { IconProps } from '../types';

export default function PinMarkerPendingIcon({
className,
...props
}: IconProps) {
return (
<svg
{...props}
className={className}
xmlns="http://www.w3.org/2000/svg"
width="56"
height="79"
viewBox="0 0 56 79"
fill="none"
aria-hidden="true"
>
<path
opacity="0.2"
d="M28 75.5985C32.6391 75.5985 36.3998 74.3449 36.3998 72.7985C36.3998 71.2521 32.6391 69.9986 28 69.9986C23.3609 69.9986 19.6002 71.2521 19.6002 72.7985C19.6002 74.3449 23.3609 75.5985 28 75.5985Z"
fill="black"
/>
<path
d="M28 44.7991V69.9986"
stroke="#3B82F6"
strokeWidth="3.49993"
strokeLinecap="round"
/>
<path
d="M28 5.59988C39.1998 5.59988 44.7997 13.9997 44.7997 25.1995C44.7997 36.3993 28 44.7991 28 44.7991C28 44.7991 11.2003 36.3993 11.2003 25.1995C11.2003 13.9997 16.8002 5.59988 28 5.59988Z"
fill="#60A5FA"
stroke="#2563EB"
strokeWidth="2.79994"
/>
<path
opacity="0.9"
d="M28 30.7994C32.6391 30.7994 36.3998 27.0386 36.3998 22.3995C36.3998 17.7604 32.6391 13.9997 28 13.9997C23.3609 13.9997 19.6002 17.7604 19.6002 22.3995C19.6002 27.0386 23.3609 30.7994 28 30.7994Z"
fill="white"
/>
{/* + 표시를 그룹으로 묶기 */}
<g className="pin-plus-icon" transform-origin="28 22.3995">
{/* 중앙 점 */}
<path
d="M28 26.5995C30.3195 26.5995 32.1999 24.7191 32.1999 22.3995C32.1999 20.08 30.3195 18.1996 28 18.1996C25.6804 18.1996 23.8001 20.08 23.8001 22.3995C23.8001 24.7191 25.6804 26.5995 28 26.5995Z"
fill="#2563EB"
/>
{/* 세로선 위 */}
<path
d="M28 13.9997V18.1996"
stroke="#2563EB"
strokeWidth="2.79994"
strokeLinecap="round"
/>
{/* 세로선 아래 */}
<path
d="M28 26.5995V30.7994"
stroke="#2563EB"
strokeWidth="2.79994"
strokeLinecap="round"
/>
{/* 가로선 왼쪽 */}
<path
d="M19.6002 22.3995H23.8001"
stroke="#2563EB"
strokeWidth="2.79994"
strokeLinecap="round"
/>
{/* 가로선 오른쪽 */}
<path
d="M32.1999 22.3995H36.3998"
stroke="#2563EB"
strokeWidth="2.79994"
strokeLinecap="round"
/>
</g>
<path
opacity="0.3"
d="M22.4001 18.1996C25.4928 18.1996 28 16.3193 28 13.9997C28 11.6802 25.4928 9.7998 22.4001 9.7998C19.3074 9.7998 16.8002 11.6802 16.8002 13.9997C16.8002 16.3193 19.3074 18.1996 22.4001 18.1996Z"
fill="white"
/>
</svg>
);
}
50 changes: 26 additions & 24 deletions apps/web/src/shared/icons/RefreshIcon.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof RefreshIcon>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {},
args: {},
};

export const WithDifferentColors: Story = {
render: () => (
<div className="flex gap-4 items-center">
<RefreshIcon className="w-6 h-6 text-blue-500" />
<RefreshIcon className="w-6 h-6 text-red-500" />
<RefreshIcon className="w-6 h-6 text-green-500" />
<RefreshIcon className="w-6 h-6 text-gray-500" />
</div>
),
render: () => (
<div className="flex gap-4 items-center">
<RefreshIcon className="w-6 h-6 text-blue-500" />
<RefreshIcon className="w-6 h-6 text-red-500" />
<RefreshIcon className="w-6 h-6 text-green-500" />
<RefreshIcon className="w-6 h-6 text-gray-500" />
</div>
),
};

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!');
},
},
};
2 changes: 2 additions & 0 deletions apps/web/src/shared/icons/index.ts
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 6 additions & 0 deletions apps/web/src/shared/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
export type { IconProps } from './icon';
export type { LoadingPageProps, LoadingPageVersion } from './loading';
export type {
Coordinates,
PinVariant,
PinMarkerData,
PinMarkerProps,
} from './marker';
18 changes: 18 additions & 0 deletions apps/web/src/shared/types/marker.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading