From 7ba9206fa9c9599907aedfcbd64e85524d3105ef Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Fri, 16 Oct 2026 13:47:25 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20dnd-kit=C2=B7es-hangul=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=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 - @dnd-kit/core·@dnd-kit/sortable 추가 - 원인: 폴더 편집의 순서 변경 드래그 - 대안: @dnd-kit/utilities·@dnd-kit/modifiers 함께 설치 - 기각: transform 문자열과 세로 축 고정은 직접 작성으로 충분 - es-hangul 추가 - 원인: 폴더 삭제 확인 문구의 을·를 결정 - 대안: 받침 판별 함수 직접 작성 - 기각: 조사 처리는 검증된 라이브러리에 맡김 --- apps/web/package.json | 3 +++ pnpm-lock.yaml | 61 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/apps/web/package.json b/apps/web/package.json index a65128f9..447b43ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,8 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@microsoft/fetch-event-source": "^2.0.1", "@next/third-parties": "^16.2.9", "@piki/core": "workspace:*", @@ -25,6 +27,7 @@ "clsx": "^2.1.1", "cropperjs": "1.6.2", "embla-carousel-react": "^8.6.0", + "es-hangul": "^2.4.0", "gsap": "^3.15.0", "html-to-image": "^1.11.13", "next": "16.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd44bb51..55c1af51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,12 @@ importers: apps/web: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) '@microsoft/fetch-event-source': specifier: ^2.0.1 version: 2.0.1 @@ -263,6 +269,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.1.0) + es-hangul: + specifier: ^2.4.0 + version: 2.4.0 gsap: specifier: ^3.15.0 version: 3.15.0 @@ -1084,6 +1093,28 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: 19.1.0 + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: 19.1.0 + react-dom: 19.1.0 + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: 19.1.0 + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: 19.1.0 + '@dotenvx/dotenvx@1.66.0': resolution: {integrity: sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw==} hasBin: true @@ -4818,6 +4849,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-hangul@2.4.0: + resolution: {integrity: sha512-9ouVct+rsUw7d5+JeyEV+Lf4PAytSK4cWnLGHM4FJDyG9BS5d3iSPnEmH/rVgmSyxyps5cWZ+NeDAlJyq8eKaw==} + es-iterator-helpers@1.2.1: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} @@ -9369,6 +9403,31 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.66.0': dependencies: commander: 11.1.0 @@ -13570,6 +13629,8 @@ snapshots: es-errors@1.3.0: {} + es-hangul@2.4.0: {} + es-iterator-helpers@1.2.1: dependencies: call-bind: 1.0.8 From 92830bb135dec85cf77c16d8316a47d52c0e0ee4 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Fri, 16 Oct 2026 13:47:25 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20BaseImage=20=EC=9E=AC=EB=A7=88?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=88=AC=EB=AA=85=20=EA=B3=A0=EC=B0=A9=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재마운트 시점에 이미지가 덜 받아졌으면 load·error 이벤트로 상태 갱신 - 원인: next/image 가 같은 src 의 load 를 한 번만 알림 - 결과: 재마운트된 이미지가 loading 상태에 남아 opacity 0 으로 보임 - 폴더 편집 순서 변경에서 발견 - 위치가 바뀐 행의 BaseImage 만 재마운트됨 --- apps/web/src/components/base-image/index.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/base-image/index.tsx b/apps/web/src/components/base-image/index.tsx index 2682aefd..3d72a780 100644 --- a/apps/web/src/components/base-image/index.tsx +++ b/apps/web/src/components/base-image/index.tsx @@ -55,7 +55,19 @@ function BaseImage({ useLayoutEffect(() => { const img = imgRef.current; if (!img) return; - setState(getImageStateFromElement(img)); + + const nextState = getImageStateFromElement(img); + setState(nextState); + if (nextState !== 'loading') return; + + /** NOTE: 폴더 편집 드래그에서 이미지 사라지는 오류 수정 */ + const syncState = () => setState(getImageStateFromElement(img)); + img.addEventListener('load', syncState); + img.addEventListener('error', syncState); + return () => { + img.removeEventListener('load', syncState); + img.removeEventListener('error', syncState); + }; }, [srcKey]); const handleLoad = (event: ImgEvent) => { From 7712a0d0c9d96c834b27bcef2d090c01f95773cd Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Fri, 16 Oct 2026 13:47:25 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20align-equal=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=8B=9C=EC=95=88=20=EA=B8=B0=EC=A4=80=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 짧은 4줄 24px 아이콘을 긴 3줄 30px 시안 아이콘으로 교체 - fill·outline 둘 다 교체 - 기존 사용처 0건 --- apps/web/src/assets/icons/fill/align-equal.svg | 14 ++++---------- apps/web/src/assets/icons/outline/align-equal.svg | 14 ++++---------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/apps/web/src/assets/icons/fill/align-equal.svg b/apps/web/src/assets/icons/fill/align-equal.svg index b738b3e3..b7087826 100644 --- a/apps/web/src/assets/icons/fill/align-equal.svg +++ b/apps/web/src/assets/icons/fill/align-equal.svg @@ -1,11 +1,5 @@ - - - - - - - - - - + + + + diff --git a/apps/web/src/assets/icons/outline/align-equal.svg b/apps/web/src/assets/icons/outline/align-equal.svg index 4f027c97..b7087826 100644 --- a/apps/web/src/assets/icons/outline/align-equal.svg +++ b/apps/web/src/assets/icons/outline/align-equal.svg @@ -1,11 +1,5 @@ - - - - - - - - - - + + + + From 1bce50a4268f1259bd58f9e0760f8f0a1ee06d96 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Fri, 16 Oct 2026 13:47:31 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=ED=8F=B4=EB=8D=94=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /wish/folder/edit 경로 추가 - 폴더 삭제 확인창 추가 - 핸들 드래그로 폴더 순서 변경 - 핸들을 잡을 때만 드래그 시작 - 키보드로도 순서 변경 가능 - 완료 버튼은 삭제나 순서 변경이 있을 때만 활성 - 바텀탭 노출 경로를 /wish/folder/{숫자} 로 제한 - 원인: /wish/folder/edit 에서도 바텀탭 노출 - 40px 폴더 썸네일을 wish/_common 에 추가 - 원인: /wish 하위 여러 화면에서 사용 - 빈 폴더 이미지도 wish/_common/_assets 에 둠 - 목 폴더 3개 추가 - API 연결 없음 - 후속: 폴더 삭제·순서 변경 API 연결 --- .../app/wish/_common/_assets/folder-empty.svg | 5 + .../_components/FolderListThumbnail.tsx | 28 ++++ .../src/app/wish/_common/_consts/wishMock.ts | 6 + .../app/wish/_components/FolderTabContent.tsx | 7 +- .../edit/_components/FolderEditContent.tsx | 132 ++++++++++++++++++ .../edit/_components/SortableFolderRow.tsx | 72 ++++++++++ apps/web/src/app/wish/folder/edit/page.tsx | 11 ++ .../src/components/bottom-tab-bar/index.tsx | 2 +- apps/web/src/consts/route.ts | 1 + 9 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/wish/_common/_assets/folder-empty.svg create mode 100644 apps/web/src/app/wish/_common/_components/FolderListThumbnail.tsx create mode 100644 apps/web/src/app/wish/folder/edit/_components/FolderEditContent.tsx create mode 100644 apps/web/src/app/wish/folder/edit/_components/SortableFolderRow.tsx create mode 100644 apps/web/src/app/wish/folder/edit/page.tsx diff --git a/apps/web/src/app/wish/_common/_assets/folder-empty.svg b/apps/web/src/app/wish/_common/_assets/folder-empty.svg new file mode 100644 index 00000000..16e25294 --- /dev/null +++ b/apps/web/src/app/wish/_common/_assets/folder-empty.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/src/app/wish/_common/_components/FolderListThumbnail.tsx b/apps/web/src/app/wish/_common/_components/FolderListThumbnail.tsx new file mode 100644 index 00000000..fd1e03dd --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/FolderListThumbnail.tsx @@ -0,0 +1,28 @@ +import BaseImage from '@/components/base-image'; +import { cn } from '@/utils/cn'; + +import FolderEmptyImage from '../_assets/folder-empty.svg'; + +type FolderListThumbnailProps = { + url: string | null; + className?: string; +}; + +function FolderListThumbnail({ url, className }: FolderListThumbnailProps) { + return ( +
+ {url ? ( + + ) : ( + + )} +
+ ); +} + +export default FolderListThumbnail; diff --git a/apps/web/src/app/wish/_common/_consts/wishMock.ts b/apps/web/src/app/wish/_common/_consts/wishMock.ts index f7935663..44a3a394 100644 --- a/apps/web/src/app/wish/_common/_consts/wishMock.ts +++ b/apps/web/src/app/wish/_common/_consts/wishMock.ts @@ -25,4 +25,10 @@ export const MOCK_WISHES: WishListItemT[] = Array.from({ length: 12 }, (_, index export const MOCK_FOLDERS: WishFolderT[] = [ { id: 1, name: '가방 위시템', wishIds: [4, 1, 2, 3, 5, 6] }, + { id: 2, name: '위시', wishIds: [1, 7] }, + { id: 3, name: '여름 코디', wishIds: [5, 8, 9] }, + { id: 4, name: '빈 폴더', wishIds: [] }, ]; + +export const getMockWishImageUrl = (wishId: number) => + MOCK_WISHES.find(({ wish }) => wish.id === wishId)?.item.imageUrl ?? null; diff --git a/apps/web/src/app/wish/_components/FolderTabContent.tsx b/apps/web/src/app/wish/_components/FolderTabContent.tsx index 21a5da5e..15cdd02a 100644 --- a/apps/web/src/app/wish/_components/FolderTabContent.tsx +++ b/apps/web/src/app/wish/_components/FolderTabContent.tsx @@ -3,14 +3,11 @@ import { useScrollRestoration } from '@/hooks/useScrollRestoration'; import { SCROLL_NAMESPACE } from '@/utils/scrollRestoration'; -import { MOCK_FOLDERS, MOCK_WISHES } from '../_common/_consts/wishMock'; +import { MOCK_FOLDERS, getMockWishImageUrl } from '../_common/_consts/wishMock'; import { WISH_TAB } from '../_consts/wishTab'; import FolderAddCard from './FolderAddCard'; import FolderCard from './FolderCard'; -const getImageUrl = (wishId: number) => - MOCK_WISHES.find(({ wish }) => wish.id === wishId)?.item.imageUrl ?? null; - function FolderTabContent() { useScrollRestoration({ namespace: SCROLL_NAMESPACE.WISH, scope: WISH_TAB.FOLDER }); @@ -25,7 +22,7 @@ function FolderTabContent() { folderId={id} name={name} wishCount={wishIds.length} - thumbnailUrls={wishIds.slice(0, 3).map(getImageUrl)} + thumbnailUrls={wishIds.slice(0, 3).map(getMockWishImageUrl)} /> ))} diff --git a/apps/web/src/app/wish/folder/edit/_components/FolderEditContent.tsx b/apps/web/src/app/wish/folder/edit/_components/FolderEditContent.tsx new file mode 100644 index 00000000..4df60775 --- /dev/null +++ b/apps/web/src/app/wish/folder/edit/_components/FolderEditContent.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { josa } from 'es-hangul'; +import { useState } from 'react'; + +import { CloseIconFill, WarningIconFill } from '@/assets/icons'; +import ConfirmDialog from '@/components/common/confirm-dialog'; +import { Header } from '@/components/header'; +import { ROUTES } from '@/consts/route'; +import { Z_INDEX } from '@/consts/zIndex'; +import { useBackWithFallback } from '@/hooks/useBackWithFallback'; +import { cn } from '@/utils/cn'; + +import { MOCK_FOLDERS } from '../../../_common/_consts/wishMock'; +import type { WishFolderT } from '../../../_common/_types/wish'; +import SortableFolderRow from './SortableFolderRow'; + +function FolderEditContent() { + const backWithFallback = useBackWithFallback(); + + /** TEMP: API 연동 시 폴더 목록 조회 응답으로 교체 */ + const [folders, setFolders] = useState(MOCK_FOLDERS); + const [deleteTarget, setDeleteTarget] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const isChanged = folders.map(({ id }) => id).join() !== MOCK_FOLDERS.map(({ id }) => id).join(); + + const handleDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + + setFolders(prev => + arrayMove( + prev, + prev.findIndex(({ id }) => id === active.id), + prev.findIndex(({ id }) => id === over.id) + ) + ); + }; + + const handleClose = () => backWithFallback(ROUTES.WISHLIST_FOLDER_TAB); + + /** TODO: 폴더 삭제·순서 변경 API 연결 */ + const handleDone = () => handleClose(); + + const handleConfirmDelete = () => { + if (!deleteTarget) return; + + setFolders(prev => prev.filter(({ id }) => id !== deleteTarget.id)); + setDeleteTarget(null); + }; + + return ( + <> +
+
+ + + } + center={

폴더 편집

} + right={ + + } + /> +
+ + + +
    + {folders.map(folder => ( + + ))} +
+
+
+ + !open && setDeleteTarget(null)} + icon={} + title={ + deleteTarget + ? `'${deleteTarget.name}'${josa.pick(deleteTarget.name, '을/를')} 삭제할까요?` + : '' + } + description="폴더를 삭제해도 담아둔 위시는 그대로 유지돼요." + confirmLabel="삭제하기" + onConfirm={handleConfirmDelete} + /> + + ); +} + +export default FolderEditContent; diff --git a/apps/web/src/app/wish/folder/edit/_components/SortableFolderRow.tsx b/apps/web/src/app/wish/folder/edit/_components/SortableFolderRow.tsx new file mode 100644 index 00000000..4c28d279 --- /dev/null +++ b/apps/web/src/app/wish/folder/edit/_components/SortableFolderRow.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useSortable } from '@dnd-kit/sortable'; + +import { AlignEqualIconFill, CloseCircularIconFill } from '@/assets/icons'; +import { cn } from '@/utils/cn'; + +import FolderListThumbnail from '../../../_common/_components/FolderListThumbnail'; +import { getMockWishImageUrl } from '../../../_common/_consts/wishMock'; +import type { WishFolderT } from '../../../_common/_types/wish'; + +type SortableFolderRowProps = { + folder: WishFolderT; + onDelete: (folder: WishFolderT) => void; +}; + +function SortableFolderRow({ folder, onDelete }: SortableFolderRowProps) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: folder.id }); + + return ( +
  • + +
    + +

    + {folder.name} + ({folder.wishIds.length}) +

    +
    + +
  • + ); +} + +export default SortableFolderRow; diff --git a/apps/web/src/app/wish/folder/edit/page.tsx b/apps/web/src/app/wish/folder/edit/page.tsx new file mode 100644 index 00000000..5986a898 --- /dev/null +++ b/apps/web/src/app/wish/folder/edit/page.tsx @@ -0,0 +1,11 @@ +import FolderEditContent from './_components/FolderEditContent'; + +function FolderEditPage() { + return ( +
    + +
    + ); +} + +export default FolderEditPage; diff --git a/apps/web/src/components/bottom-tab-bar/index.tsx b/apps/web/src/components/bottom-tab-bar/index.tsx index affd737a..0bce847b 100644 --- a/apps/web/src/components/bottom-tab-bar/index.tsx +++ b/apps/web/src/components/bottom-tab-bar/index.tsx @@ -85,7 +85,7 @@ function BottomTabBar() { const [isPressed, setIsPressed] = useState(false); const [isGrabbing, setIsGrabbing] = useState(false); - const isWishFolderRoute = /^\/wish(?:\/folder\/[^/]+)?$/.test(pathname); // TODO: /wish/folder/:folderId 만 해당되도록 조건 변경 필요. 현재는 /wish, /wish/folder/:folderId 둘 다 해당. 변수명은 이관 이후 기준으로 작성 + const isWishFolderRoute = /^\/wish(?:\/folder\/\d+)?$/.test(pathname); // TODO: /wish/folder/:folderId 만 해당되도록 조건 변경 필요. 현재는 /wish, /wish/folder/:folderId 둘 다 해당. 변수명은 이관 이후 기준으로 작성 const activeIndex = TABS.findIndex( ({ href }) => matchesTabPath(pathname, href) || (isWishFolderRoute && href === ROUTES.WISHLIST) ); diff --git a/apps/web/src/consts/route.ts b/apps/web/src/consts/route.ts index 4986b5de..02328070 100644 --- a/apps/web/src/consts/route.ts +++ b/apps/web/src/consts/route.ts @@ -25,6 +25,7 @@ export const ROUTES = { WISH: '/wish', // TEMP WISHLIST_FOLDER_TAB: '/wish?tab=folder', WISHLIST_FOLDER: (folderId: number) => `/wish/folder/${folderId}`, + WISHLIST_FOLDER_EDIT: '/wish/folder/edit', NOTIFICATION: '/notification', MYPAGE_EDIT: '/mypage/edit', MYPAGE_WITHDRAW: '/mypage/withdraw', From d2088bf3e45cf51b6fe0aad542885c4c33bc4fe3 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Fri, 16 Oct 2026 13:47:39 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=9C=84=EC=8B=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 위시 탭·폴더 상세에 편집 모드 추가 - 헤더는 상품 선택·상품 n개 선택·완료 - 카드마다 체크박스 - 하단 액션 바가 바텀탭을 덮음 - 편집 모드 상태를 mode(default·edit)로 관리 - 대안: isSelecting boolean - 기각: 편집 버튼과 이름이 바로 이어지지 않음 - 삭제 확인 바텀시트 추가 - 폴더 선택 바텀시트 추가 - 시트 안에서 폴더 추가 가능 - 현재 폴더는 목록에서 제외 - 폴더 상세 삭제는 시안 문구 기준 - 조건: 폴더에서 빼기·위시 삭제 중 동작 결정 보류 - 전체 위시 탭 편집 모드는 폴더 상세와 같은 형태 - 원인: 시안 없음 - 폴더 탭 편집 버튼을 폴더 편집 화면으로 연결 - API 연결 없음 - 후속: 위시 삭제·폴더 이동 API 연결 --- .../_common/_components/FolderSelectSheet.tsx | 138 ++++++++++++++++++ .../_common/_components/WishDeleteSheet.tsx | 47 ++++++ .../_common/_components/WishEditActionBar.tsx | 120 +++++++++++++++ .../_common/_components/WishEditHeader.tsx | 29 ++++ .../app/wish/_common/_components/WishGrid.tsx | 41 +++++- .../src/app/wish/_common/_consts/wishMode.ts | 4 + .../wish/_common/_hooks/useWishEditMode.ts | 36 +++++ .../src/app/wish/_common/_types/wishMode.ts | 3 + .../wish/_components/AllWishTabContent.tsx | 9 +- .../app/wish/_components/WishListContent.tsx | 79 ++++++++++ .../app/wish/_components/WishTabContent.tsx | 10 +- .../app/wish/_components/WishTabSummary.tsx | 30 +++- .../_components/FolderDetailContent.tsx | 111 ++++++++++---- apps/web/src/app/wish/page.tsx | 20 +-- 14 files changed, 612 insertions(+), 65 deletions(-) create mode 100644 apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx create mode 100644 apps/web/src/app/wish/_common/_components/WishDeleteSheet.tsx create mode 100644 apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx create mode 100644 apps/web/src/app/wish/_common/_components/WishEditHeader.tsx create mode 100644 apps/web/src/app/wish/_common/_consts/wishMode.ts create mode 100644 apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts create mode 100644 apps/web/src/app/wish/_common/_types/wishMode.ts create mode 100644 apps/web/src/app/wish/_components/WishListContent.tsx diff --git a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx new file mode 100644 index 00000000..e1fdf25b --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { type FormEvent, useState } from 'react'; + +import { ChevronBackwardIconFill, FolderAddIconFill } from '@/assets/icons'; +import Button from '@/components/button'; +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; +import { Header } from '@/components/header'; +import Input from '@/components/input'; + +import { getMockWishImageUrl } from '../_consts/wishMock'; +import type { WishFolderT } from '../_types/wish'; +import FolderListThumbnail from './FolderListThumbnail'; + +type FolderSelectSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + folders: WishFolderT[]; + onSelect: (folder: WishFolderT) => void; + onAddFolder: (name: string) => void; +}; + +function FolderSelectSheet({ + open, + onOpenChange, + folders, + onSelect, + onAddFolder, +}: FolderSelectSheetProps) { + const [isAddView, setIsAddView] = useState(false); + const [folderName, setFolderName] = useState(''); + + const trimmedFolderName = folderName.trim(); + + const handleBackToList = () => { + setIsAddView(false); + setFolderName(''); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) handleBackToList(); + onOpenChange(isOpen); + }; + + const handleAddSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!trimmedFolderName) return; + + onAddFolder(trimmedFolderName); + handleBackToList(); + }; + + return ( + + + + {isAddView ? '새 폴더 이름을 입력하세요.' : '상품을 옮길 폴더를 선택하세요.'} + + {isAddView ? ( +
    +
    + + + } + center={ + + 폴더 추가 + + } + /> +
    + setFolderName(event.target.value)} + autoFocus + /> + +
    + + ) : ( +
    +
    + 폴더 선택 + + } + right={ + + } + /> +
      + {folders.map(folder => ( +
    • + +
    • + ))} +
    +
    + )} + + + ); +} + +export default FolderSelectSheet; diff --git a/apps/web/src/app/wish/_common/_components/WishDeleteSheet.tsx b/apps/web/src/app/wish/_common/_components/WishDeleteSheet.tsx new file mode 100644 index 00000000..619077f8 --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/WishDeleteSheet.tsx @@ -0,0 +1,47 @@ +'use client'; + +import Button from '@/components/button'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from '@/components/drawer'; + +type WishDeleteSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedCount: number; + onConfirm: () => void; +}; + +function WishDeleteSheet({ open, onOpenChange, selectedCount, onConfirm }: WishDeleteSheetProps) { + return ( + + + + + 상품을 삭제할까요? + + + 선택하신 상품 {selectedCount}개가 삭제됩니다. + + +
    + + + + +
    +
    +
    + ); +} + +export default WishDeleteSheet; diff --git a/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx b/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx new file mode 100644 index 00000000..88fe8393 --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { type ComponentType, type SVGProps, useState } from 'react'; + +import { FolderIconFill, TrashIconFill } from '@/assets/icons'; +import { Z_INDEX } from '@/consts/zIndex'; +import { cn } from '@/utils/cn'; + +import { MOCK_FOLDERS } from '../_consts/wishMock'; +import type { WishFolderT } from '../_types/wish'; +import FolderSelectSheet from './FolderSelectSheet'; +import WishDeleteSheet from './WishDeleteSheet'; + +type WishEditActionBarProps = { + selectedCount: number; + currentFolderId?: number; + onMove: (folder: WishFolderT) => void; + onDelete: () => void; +}; + +function ActionButton({ + label, + Icon, + disabled, + onClick, +}: { + label: string; + Icon: ComponentType>; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function WishEditActionBar({ + selectedCount, + currentFolderId, + onMove, + onDelete, +}: WishEditActionBarProps) { + const [isFolderSheetOpen, setIsFolderSheetOpen] = useState(false); + const [isDeleteSheetOpen, setIsDeleteSheetOpen] = useState(false); + /** TEMP: API 연동 시 폴더 목록 조회·생성 API 로 교체 */ + const [folders, setFolders] = useState(() => + MOCK_FOLDERS.filter(({ id }) => id !== currentFolderId) + ); + + const isDisabled = selectedCount === 0; + + const handleAddFolder = (name: string) => { + setFolders(prev => [{ id: Date.now(), name, wishIds: [] }, ...prev]); + }; + + const handleSelectFolder = (folder: WishFolderT) => { + setIsFolderSheetOpen(false); + onMove(folder); + }; + + const handleConfirmDelete = () => { + setIsDeleteSheetOpen(false); + onDelete(); + }; + + return ( + <> +
    + setIsFolderSheetOpen(true)} + /> + setIsDeleteSheetOpen(true)} + /> +
    + + + + + ); +} + +export default WishEditActionBar; diff --git a/apps/web/src/app/wish/_common/_components/WishEditHeader.tsx b/apps/web/src/app/wish/_common/_components/WishEditHeader.tsx new file mode 100644 index 00000000..337c094b --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/WishEditHeader.tsx @@ -0,0 +1,29 @@ +import { Header } from '@/components/header'; + +type WishEditHeaderProps = { + selectedCount: number; + onDone: () => void; +}; + +function WishEditHeader({ selectedCount, onDone }: WishEditHeaderProps) { + return ( +
    + {selectedCount > 0 ? `상품 ${selectedCount}개 선택` : '상품 선택'} + + } + right={ + + } + /> + ); +} + +export default WishEditHeader; diff --git a/apps/web/src/app/wish/_common/_components/WishGrid.tsx b/apps/web/src/app/wish/_common/_components/WishGrid.tsx index ba2515e3..766d35db 100644 --- a/apps/web/src/app/wish/_common/_components/WishGrid.tsx +++ b/apps/web/src/app/wish/_common/_components/WishGrid.tsx @@ -1,19 +1,24 @@ +import { CheckboxEmptyIconFill, CheckboxSelectedIconFill } from '@/assets/icons'; +import { Z_INDEX } from '@/consts/zIndex'; + import type { WishListItemT } from '../_types/wish'; import WishCard from './WishCard'; type WishGridProps = { wishes: WishListItemT[]; + selectedWishIds?: Set; + onToggleSelect?: (wishId: number) => void; }; /** 이미지 preload 개수 */ const PRELOAD_COUNT = 6; /** 위시 3열 그리드 **/ -function WishGrid({ wishes }: WishGridProps) { +function WishGrid({ wishes, selectedWishIds, onToggleSelect }: WishGridProps) { return (
      - {wishes.map(({ wish, item }, index) => ( -
    • + {wishes.map(({ wish, item }, index) => { + const card = ( -
    • - ))} + ); + + if (!selectedWishIds) return
    • {card}
    • ; + + const isSelected = selectedWishIds.has(wish.id); + return ( +
    • + +
    • + ); + })}
    ); } diff --git a/apps/web/src/app/wish/_common/_consts/wishMode.ts b/apps/web/src/app/wish/_common/_consts/wishMode.ts new file mode 100644 index 00000000..cd0a2744 --- /dev/null +++ b/apps/web/src/app/wish/_common/_consts/wishMode.ts @@ -0,0 +1,4 @@ +export const WISH_MODE = { + DEFAULT: 'default', + EDIT: 'edit', +} as const; diff --git a/apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts b/apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts new file mode 100644 index 00000000..2c838df9 --- /dev/null +++ b/apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts @@ -0,0 +1,36 @@ +import { useState } from 'react'; + +import { WISH_MODE } from '../_consts/wishMode'; +import type { WishModeT } from '../_types/wishMode'; + +export const useWishEditMode = () => { + const [mode, setMode] = useState(WISH_MODE.DEFAULT); + const [selectedWishIds, setSelectedWishIds] = useState>(new Set()); + + const handleToggleSelect = (wishId: number) => { + setSelectedWishIds(prev => { + const next = new Set(prev); + if (next.has(wishId)) next.delete(wishId); + else next.add(wishId); + return next; + }); + }; + + const handleClearSelection = () => setSelectedWishIds(new Set()); + + const handleEnterEditMode = () => setMode(WISH_MODE.EDIT); + + const handleExitEditMode = () => { + setMode(WISH_MODE.DEFAULT); + handleClearSelection(); + }; + + return { + mode, + selectedWishIds, + handleToggleSelect, + handleClearSelection, + handleEnterEditMode, + handleExitEditMode, + }; +}; diff --git a/apps/web/src/app/wish/_common/_types/wishMode.ts b/apps/web/src/app/wish/_common/_types/wishMode.ts new file mode 100644 index 00000000..4581d87d --- /dev/null +++ b/apps/web/src/app/wish/_common/_types/wishMode.ts @@ -0,0 +1,3 @@ +import type { WISH_MODE } from '../_consts/wishMode'; + +export type WishModeT = (typeof WISH_MODE)[keyof typeof WISH_MODE]; diff --git a/apps/web/src/app/wish/_components/AllWishTabContent.tsx b/apps/web/src/app/wish/_components/AllWishTabContent.tsx index 92622de3..9405f1ff 100644 --- a/apps/web/src/app/wish/_components/AllWishTabContent.tsx +++ b/apps/web/src/app/wish/_components/AllWishTabContent.tsx @@ -1,9 +1,12 @@ +import type { ComponentProps } from 'react'; + import WishGrid from '../_common/_components/WishGrid'; -import { MOCK_WISHES } from '../_common/_consts/wishMock'; -function AllWishTabContent() { +export type AllWishTabContentProps = ComponentProps; + +function AllWishTabContent(props: AllWishTabContentProps) { /** TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: all) 추가 */ - return ; + return ; } export default AllWishTabContent; diff --git a/apps/web/src/app/wish/_components/WishListContent.tsx b/apps/web/src/app/wish/_components/WishListContent.tsx new file mode 100644 index 00000000..c5cd5cc6 --- /dev/null +++ b/apps/web/src/app/wish/_components/WishListContent.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { Z_INDEX } from '@/consts/zIndex'; + +import WishEditActionBar from '../_common/_components/WishEditActionBar'; +import WishEditHeader from '../_common/_components/WishEditHeader'; +import WishSearchLink from '../_common/_components/WishSearchLink'; +import { MOCK_WISHES } from '../_common/_consts/wishMock'; +import { WISH_MODE } from '../_common/_consts/wishMode'; +import { useWishEditMode } from '../_common/_hooks/useWishEditMode'; +import type { WishFolderT } from '../_common/_types/wish'; +import WishTabBar from './WishTabBar'; +import WishTabContent from './WishTabContent'; +import WishTabSummary from './WishTabSummary'; + +function WishListContent() { + /** TEMP: API 연동 시 위시 목록 조회 응답으로 교체 */ + const [wishes, setWishes] = useState(MOCK_WISHES); + const { + mode, + selectedWishIds, + handleToggleSelect, + handleClearSelection, + handleEnterEditMode, + handleExitEditMode, + } = useWishEditMode(); + + /** TODO: 폴더 추가 API 연결. 기존 소속 폴더는 유지됨 */ + const handleMove = (folder: WishFolderT) => { + handleClearSelection(); + toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`); + }; + + /** TODO: 위시 삭제 API 연결 */ + const handleDelete = () => { + setWishes(prev => prev.filter(({ wish }) => !selectedWishIds.has(wish.id))); + handleClearSelection(); + toast.success(`상품 ${selectedWishIds.size}개를 삭제했어요.`); + }; + + return ( + <> +
    + {mode === WISH_MODE.EDIT ? ( + + ) : ( + <> +
    +

    내 위시리스트

    + +
    + + + )} + +
    + + + {mode === WISH_MODE.EDIT && ( + + )} + + ); +} + +export default WishListContent; diff --git a/apps/web/src/app/wish/_components/WishTabContent.tsx b/apps/web/src/app/wish/_components/WishTabContent.tsx index 319adae5..92339052 100644 --- a/apps/web/src/app/wish/_components/WishTabContent.tsx +++ b/apps/web/src/app/wish/_components/WishTabContent.tsx @@ -8,10 +8,10 @@ import { getScrollContainer } from '@/utils/scrollRestoration'; import { WISH_TAB, WISH_TAB_PANEL_ID } from '../_consts/wishTab'; import type { WishTabT } from '../_types/wishTab'; import { parseWishTabParam } from '../_utils/wishSearchParams'; -import AllWishTabContent from './AllWishTabContent'; +import AllWishTabContent, { type AllWishTabContentProps } from './AllWishTabContent'; import FolderTabContent from './FolderTabContent'; -function WishTabContent() { +function WishTabContent(allWishTabContentProps: AllWishTabContentProps) { const searchParams = useSearchParams(); const activeTab = parseWishTabParam(searchParams.get('tab')); @@ -41,7 +41,11 @@ function WishTabContent() { return (
    - {activeTab === WISH_TAB.FOLDER ? : } + {activeTab === WISH_TAB.FOLDER ? ( + + ) : ( + + )}
    ); } diff --git a/apps/web/src/app/wish/_components/WishTabSummary.tsx b/apps/web/src/app/wish/_components/WishTabSummary.tsx index 6282ce12..bf0583ba 100644 --- a/apps/web/src/app/wish/_components/WishTabSummary.tsx +++ b/apps/web/src/app/wish/_components/WishTabSummary.tsx @@ -1,18 +1,29 @@ 'use client'; +import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { MOCK_FOLDERS, MOCK_WISHES } from '../_common/_consts/wishMock'; +import { ROUTES } from '@/consts/route'; + +import { MOCK_FOLDERS } from '../_common/_consts/wishMock'; +import { WISH_MODE } from '../_common/_consts/wishMode'; +import type { WishModeT } from '../_common/_types/wishMode'; import { WISH_TAB } from '../_consts/wishTab'; import { parseWishTabParam } from '../_utils/wishSearchParams'; const MAX_DISPLAY_COUNT = 999; -function WishTabSummary() { +type WishTabSummaryProps = { + wishCount: number; + mode: WishModeT; + onEdit: () => void; +}; + +function WishTabSummary({ wishCount, mode, onEdit }: WishTabSummaryProps) { const searchParams = useSearchParams(); const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER; - const wishCount = MOCK_WISHES.length; + const editClassName = 'cursor-pointer body-2-medium text-text-neutral-secondary'; return (
    @@ -21,9 +32,16 @@ function WishTabSummary() { ? `폴더 ${MOCK_FOLDERS.length}개` : `상품 ${wishCount > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : wishCount}`}

    - + {isFolderTab && ( + + 편집 + + )} + {!isFolderTab && mode === WISH_MODE.DEFAULT && ( + + )}
    ); } diff --git a/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx index 8f368bbe..9e300708 100644 --- a/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx +++ b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx @@ -1,7 +1,7 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { ChevronBackwardIconFill } from '@/assets/icons'; @@ -11,9 +11,14 @@ import { ROUTES } from '@/consts/route'; import { Z_INDEX } from '@/consts/zIndex'; import { useBackWithFallback } from '@/hooks/useBackWithFallback'; +import WishEditActionBar from '../../../_common/_components/WishEditActionBar'; +import WishEditHeader from '../../../_common/_components/WishEditHeader'; import WishGrid from '../../../_common/_components/WishGrid'; import WishSearchLink from '../../../_common/_components/WishSearchLink'; import { MOCK_FOLDERS, MOCK_WISHES } from '../../../_common/_consts/wishMock'; +import { WISH_MODE } from '../../../_common/_consts/wishMode'; +import { useWishEditMode } from '../../../_common/_hooks/useWishEditMode'; +import type { WishFolderT } from '../../../_common/_types/wish'; type FolderDetailContentProps = { folderId: number | null; @@ -25,6 +30,19 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { const folder = MOCK_FOLDERS.find(({ id }) => id === folderId); + /** TEMP: API 연동 시 폴더 위시 조회 응답으로 교체 */ + const [wishes, setWishes] = useState(() => + MOCK_WISHES.filter(({ wish }) => folder?.wishIds.includes(wish.id)) + ); + const { + mode, + selectedWishIds, + handleToggleSelect, + handleClearSelection, + handleEnterEditMode, + handleExitEditMode, + } = useWishEditMode(); + /** 없는 폴더는 안내 후 폴더 탭으로 — 기록에 남기지 않는다 */ useEffect(() => { if (folder) return; @@ -35,7 +53,19 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { if (!folder) return null; - const wishes = MOCK_WISHES.filter(({ wish }) => folder.wishIds.includes(wish.id)); + /** TODO: 폴더 이동 API 연결 */ + const handleMove = (targetFolder: WishFolderT) => { + setWishes(prev => prev.filter(({ wish }) => !selectedWishIds.has(wish.id))); + handleClearSelection(); + toast.success(`상품을 '${targetFolder.name}' 폴더로 이동했어요.`); + }; + + /** TODO: 폴더에서 빼기·위시 삭제 중 동작 확정 후 API 연결 */ + const handleDelete = () => { + setWishes(prev => prev.filter(({ wish }) => !selectedWishIds.has(wish.id))); + handleClearSelection(); + toast.success(`폴더에서 상품 ${selectedWishIds.size}개를 삭제했어요.`); + }; return ( <> @@ -43,26 +73,32 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { className="sticky top-0 bg-bg-layer-basement pt-padding-top" style={{ zIndex: Z_INDEX.STICKY_HEADER }} > -
    -
    backWithFallback(ROUTES.WISHLIST_FOLDER_TAB)} - className="cursor-pointer" - > - - - } - center={ -

    - {folder.name} -

    - } - right={} - /> -
    + {mode === WISH_MODE.EDIT ? ( +
    + +
    + ) : ( +
    +
    backWithFallback(ROUTES.WISHLIST_FOLDER_TAB)} + className="cursor-pointer" + > + + + } + center={ +

    + {folder.name} +

    + } + right={} + /> +
    + )}

    @@ -70,18 +106,33 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { 전체({wishes.length})

    - + {mode === WISH_MODE.DEFAULT && ( + + )}
    - + {/* TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: folderId) 추가 */} - + + + {mode === WISH_MODE.EDIT && ( + + )} ); } diff --git a/apps/web/src/app/wish/page.tsx b/apps/web/src/app/wish/page.tsx index b41dd867..aa57d4e0 100644 --- a/apps/web/src/app/wish/page.tsx +++ b/apps/web/src/app/wish/page.tsx @@ -1,25 +1,9 @@ -import { Z_INDEX } from '@/consts/zIndex'; - -import WishSearchLink from './_common/_components/WishSearchLink'; -import WishTabBar from './_components/WishTabBar'; -import WishTabContent from './_components/WishTabContent'; -import WishTabSummary from './_components/WishTabSummary'; +import WishListContent from './_components/WishListContent'; function WishPage() { return (
    -
    -
    -

    내 위시리스트

    - -
    - - -
    - +
    ); }