diff --git a/package.json b/package.json index 6e252340..177812be 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,13 @@ "axios": "latest", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "radix-ui": "^1.6.1", "lucide-react": "^1.23.0", + "radix-ui": "^1.6.1", "react": "latest", "react-dom": "latest", "react-router-dom": "latest", "tailwind-merge": "^3.6.0", + "vaul": "^1.1.2", "zod": "^4.4.3", "zustand": "latest" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb585c12..8245358a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + vaul: + specifier: ^1.1.2 + version: 1.1.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) zod: specifier: ^4.4.3 version: 4.4.3 @@ -4785,6 +4788,15 @@ packages: } engines: { node: '>= 0.8' } + vaul@1.1.2: + resolution: + { + integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite-plugin-svgr@5.2.0: resolution: { @@ -7982,6 +7994,15 @@ snapshots: vary@1.1.2: {} + vaul@1.1.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): + dependencies: + '@radix-ui/react-dialog': 1.1.18(@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) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vite-plugin-svgr@5.2.0(typescript@6.0.3)(vite@8.1.3(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 5.4.0 diff --git a/src/assets/icons/bookmark.svg b/src/assets/icons/bookmark.svg new file mode 100644 index 00000000..7d4e8d7c --- /dev/null +++ b/src/assets/icons/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/heart.svg b/src/assets/icons/heart.svg new file mode 100644 index 00000000..69da18c5 --- /dev/null +++ b/src/assets/icons/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/next.svg b/src/assets/icons/next.svg new file mode 100644 index 00000000..7bdf55d1 --- /dev/null +++ b/src/assets/icons/next.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/ui/BottomSheet.tsx b/src/components/ui/BottomSheet.tsx new file mode 100644 index 00000000..d847c994 --- /dev/null +++ b/src/components/ui/BottomSheet.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { Drawer as DrawerPrimitive } from 'vaul'; +import { TopBar } from '@/components/ui/TopBar'; + +import { cn } from '@/lib/utils'; + +type SnapPoint = number | string; + +const DEFAULT_SNAP_POINTS: SnapPoint[] = [0.5, 1]; + +/** Vaul Drawer 루트. 시트 열림/닫힘·스냅 포인트 상태를 관리한다. */ +function SheetRoot({ ...props }: React.ComponentProps) { + return ; +} + +/** 시트를 document body 포털로 렌더링한다. */ +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +type SheetContentProps = React.ComponentProps; + +/** 화면 하단에 고정되는 시트 패널. 기본 레이아웃·스타일을 적용한다. */ +function SheetContent({ className, children, ...props }: SheetContentProps) { + return ( + + + {children} + + + ); +} + +/** 상단 드래그 핸들. 사용자가 시트 높이를 조절할 때 잡는 영역이다. */ +function SheetHandle({ ...props }: React.ComponentProps) { + return ( + + ); +} + +type BottomSheetContextValue = { + isFullPage: boolean; + onClose: () => void; + /** 풀페이지 → 이전 스냅(절반)으로 내리기 */ + collapse: () => void; +}; + +const BottomSheetContext = React.createContext(null); + +/** BottomSheet 하위 컴포넌트에서 풀페이지 여부·닫기·접기 상태에 접근하는 훅. */ +function useBottomSheet() { + const context = React.useContext(BottomSheetContext); + if (!context) { + throw new Error('useBottomSheet must be used within BottomSheet'); + } + return context; +} + +type BottomSheetProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; + /** drag handle 표시 여부 */ + showHandle?: boolean; + /** 오버레이 클릭·드래그로 닫기 */ + dismissible?: boolean; + /** + * 스냅 지점(높이 단계). 기본 [0.5, 1] = 절반 → 전체. + * 마지막 지점(끝까지 올림)에 도달하면 페이지 형식으로 전환된다. + */ + snapPoints?: SnapPoint[]; + className?: string; +}; + +/** + * 바텀시트 메인 컴포넌트. + * 스냅 포인트(기본 절반 → 전체)를 제어하고, 마지막 스냅에 도달하면 풀페이지 UI로 전환한다. + */ +function BottomSheet({ + open, + onClose, + children, + showHandle = true, + dismissible = true, + snapPoints = DEFAULT_SNAP_POINTS, + className, +}: BottomSheetProps) { + const firstSnap = snapPoints[0] ?? null; + const lastSnap = snapPoints[snapPoints.length - 1]; + const [activeSnap, setActiveSnap] = React.useState(firstSnap); + const [prevOpen, setPrevOpen] = React.useState(open); + + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setActiveSnap(firstSnap); + } + } + + const isFullPage = activeSnap === lastSnap; + + const collapse = React.useCallback(() => { + setActiveSnap(firstSnap); + }, [firstSnap]); + + return ( + + { + if (!isOpen) { + onClose(); + } + }} + > + + {showHandle && !isFullPage ? : null} +
{children}
+
+
+
+ ); +} + +type SectionProps = React.ComponentProps<'div'>; + +type TitleProps = React.ComponentProps; + +/** 시트 상단 고정 영역. 제목·검색창 등 스크롤되지 않는 헤더에 사용한다. */ +function BottomSheetHeader({ className, ...props }: SectionProps) { + return
; +} + +/** 시트 접근성 제목. Header 안의 h2를 이 컴포넌트로 감싼다. */ +function BottomSheetTitle({ className, ...props }: TitleProps) { + return ; +} + +/** 시트 본문 스크롤 영역. 리스트·카드 등 긴 콘텐츠를 담는다. */ +function BottomSheetContent({ className, ...props }: SectionProps) { + return ( +
+ ); +} + +type FullPageNavProps = React.ComponentProps; + +/** 풀페이지 모드에서만 표시되는 상단 네비. 뒤로(접기)·닫기 버튼을 제공한다. */ +function BottomSheetFullPageNav(props: FullPageNavProps) { + const { isFullPage, collapse, onClose } = useBottomSheet(); + + if (!isFullPage) return null; + + return ; +} + +BottomSheet.Header = BottomSheetHeader; +BottomSheet.Title = BottomSheetTitle; +BottomSheet.Content = BottomSheetContent; +BottomSheet.FullPageNav = BottomSheetFullPageNav; + +export { BottomSheet, useBottomSheet }; diff --git a/src/features/pin/components/PinCard.tsx b/src/features/pin/components/PinCard.tsx new file mode 100644 index 00000000..ce9311bc --- /dev/null +++ b/src/features/pin/components/PinCard.tsx @@ -0,0 +1,46 @@ +import { cn } from '@/lib/utils'; +import type { Pin } from '@/types/pin'; + +import NextIcon from '@/assets/icons/next.svg?react'; +import HeartIcon from '@/assets/icons/heart.svg?react'; + +type PingCardProps = { + pin: Pin; + onClick?: () => void; +}; + +export function PinCard({ pin, onClick }: PingCardProps) { + const { title, artist, pinCount, likeCount, liked } = pin; + + return ( + + ); +} diff --git a/src/features/pin/components/PinListSheet.tsx b/src/features/pin/components/PinListSheet.tsx new file mode 100644 index 00000000..7fc1c472 --- /dev/null +++ b/src/features/pin/components/PinListSheet.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; + +import { BottomSheet } from '@/components/ui/BottomSheet'; +import { PinCard } from '@/features/pin/components/PinCard'; +import { SortTabs } from '@/features/pin/components/SortTabs'; +import BookmarkIcon from '@/assets/icons/bookmark.svg?react'; +import type { Pin, PinSort, PlaceInfo } from '@/types/pin'; + +type PinListSheetProps = { + place?: PlaceInfo; + pins?: Pin[]; + onPinClick?: (pin: Pin) => void; +}; + +const MOCK_PLACE: PlaceInfo = { + id: '1', + name: '물빛무대 앞 광장', + creatorName: '냥코', + distance: 100, +}; + +const MOCK_PINS: Pin[] = [ + { + id: '1', + title: 'LOVE ATTACK', + artist: 'RESCENE', + pinCount: 1, + likeCount: 1, + liked: true, + }, +]; + +export function PinListSheet({ + place = MOCK_PLACE, + pins = MOCK_PINS, + onPinClick, +}: PinListSheetProps) { + const [open, setOpen] = useState(false); + const [sort, setSort] = useState('popular'); + + return ( +
+ {/* 지도 부분 onclick시 바텀시트 열기 */} +

PIN 목록 바텀시트 미리보기

+ + + + setOpen(false)}> + + + +
+
+ + {place.name} + +

+ {place.creatorName} 님이 + 생성한 PIN · {place.distance}m +

+
+ + +
+
+ +
+
+ + +
    + {pins.map((pin) => ( +
  • + onPinClick?.(pin)} /> +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/features/pin/components/SongBottom.tsx b/src/features/pin/components/SongBottom.tsx new file mode 100644 index 00000000..bcfd8353 --- /dev/null +++ b/src/features/pin/components/SongBottom.tsx @@ -0,0 +1,80 @@ +import { useMemo, useState } from 'react'; + +import { BottomSheet } from '@/components/ui/BottomSheet'; +import { SearchInput } from '@/components/ui/SearchInput'; +import { SongCard } from '@/features/pin/components/SongCard'; +import type { Song } from '@/types/pin'; + +type SongCardProps = { + songs?: Song[]; + onSelect?: (song: Song) => void; +}; + +const MOCK_SONGS: Song[] = [ + { id: '1', title: 'Hype Boy', artist: 'NewJeans' }, + { id: '2', title: 'Hype Boy (250 Remix)', artist: 'NewJeans' }, + { id: '3', title: 'Hype Boy (Instrumental)', artist: 'NewJeans' }, + { id: '4', title: 'Attention', artist: 'NewJeans' }, +]; + +export default function SongPage({ songs = MOCK_SONGS, onSelect }: SongCardProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + + const filteredSongs = useMemo(() => { + const keyword = query.trim().toLowerCase(); + if (!keyword) return songs; + + return songs.filter( + (song) => + song.title.toLowerCase().includes(keyword) || song.artist.toLowerCase().includes(keyword), + ); + }, [query, songs]); + + return ( +
+ {/* 지도 부분 onclick시 바텀시트 열기 */} +

노래 선택 바텀시트 미리보기

+ + + + setOpen(false)}> + + + 노래 선택하기 + + + setQuery(event.target.value)} + placeholder="노래를 검색하세요" + /> + + + +
    + {filteredSongs.map((song) => ( +
  • + { + onSelect?.(song); + setOpen(false); + setQuery(''); + }} + /> +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/features/pin/components/SongCard.tsx b/src/features/pin/components/SongCard.tsx new file mode 100644 index 00000000..83b3ca7c --- /dev/null +++ b/src/features/pin/components/SongCard.tsx @@ -0,0 +1,22 @@ +import type { Song } from '@/types/pin'; + +type SongCardProps = { + song: Song; + onClick?: () => void; +}; + +export function SongCard({ song, onClick }: SongCardProps) { + return ( + + ); +} diff --git a/src/features/pin/components/SortTabs.tsx b/src/features/pin/components/SortTabs.tsx new file mode 100644 index 00000000..d176bd3b --- /dev/null +++ b/src/features/pin/components/SortTabs.tsx @@ -0,0 +1,37 @@ +import { cn } from '@/lib/utils'; +import type { PinSort } from '@/types/pin'; + +type SortTabsProps = { + value: PinSort; + onChange: (value: PinSort) => void; +}; + +const OPTIONS: { value: PinSort; label: string }[] = [ + { value: 'popular', label: '인기순' }, + { value: 'latest', label: '최신순' }, +]; + +export function SortTabs({ value, onChange }: SortTabsProps) { + return ( +
+ {OPTIONS.map((option) => { + const selected = value === option.value; + + return ( + + ); + })} +
+ ); +} diff --git a/src/types/pin.ts b/src/types/pin.ts new file mode 100644 index 00000000..662850af --- /dev/null +++ b/src/types/pin.ts @@ -0,0 +1,23 @@ +export type PinSort = 'popular' | 'latest'; + +export type PlaceInfo = { + id: string; + name: string; + creatorName: string; + distance: number; +}; + +export type Pin = { + id: string; + title: string; + artist: string; + pinCount: number; + likeCount?: number; + liked?: boolean; +}; + +export type Song = { + id: string; + title: string; + artist: string; +};