From e318ba39295cf79ebe06906f6470e7c8d436e5e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Tue, 6 Oct 2026 22:54:28 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(icons):=20=EC=B4=88=EB=8C=80=C2=B7?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=EC=9B=90=20=EA=B4=80=EB=A6=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Figma 원본(24×24) copy, copy-check, refresh, user-edit, user-x, menu-dot-horizontal 추가 - Figma 미등록 종이비행기는 Tabler send(outline)를 stroke 1.8로 임시 추가 - icons/index.tsx에 export 추가 Co-Authored-By: Claude Opus 5.5 --- src/assets/icons/copy-check.svg | 5 +++++ src/assets/icons/copy.svg | 4 ++++ src/assets/icons/index.tsx | 16 +++++++++++++++- src/assets/icons/menu-dot-horizontal.svg | 5 +++++ src/assets/icons/refresh.svg | 4 ++++ src/assets/icons/send.svg | 4 ++++ src/assets/icons/user-edit.svg | 5 +++++ src/assets/icons/user-x.svg | 6 ++++++ 8 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/assets/icons/copy-check.svg create mode 100644 src/assets/icons/copy.svg create mode 100644 src/assets/icons/menu-dot-horizontal.svg create mode 100644 src/assets/icons/refresh.svg create mode 100644 src/assets/icons/send.svg create mode 100644 src/assets/icons/user-edit.svg create mode 100644 src/assets/icons/user-x.svg diff --git a/src/assets/icons/copy-check.svg b/src/assets/icons/copy-check.svg new file mode 100644 index 00000000..248d9485 --- /dev/null +++ b/src/assets/icons/copy-check.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/copy.svg b/src/assets/icons/copy.svg new file mode 100644 index 00000000..17cfcb28 --- /dev/null +++ b/src/assets/icons/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/index.tsx b/src/assets/icons/index.tsx index fc2f041c..b501ca7f 100644 --- a/src/assets/icons/index.tsx +++ b/src/assets/icons/index.tsx @@ -6,19 +6,26 @@ import ChevronLeftSvg from "@/assets/icons/chevron-left.svg"; import CircleCancelSvg from "@/assets/icons/circle-cancel.svg"; import CloseSvg from "@/assets/icons/close.svg"; import CoinSvg from "@/assets/icons/coin.svg"; +import CopySvg from "@/assets/icons/copy.svg"; +import CopyCheckSvg from "@/assets/icons/copy-check.svg"; import LinkSvg from "@/assets/icons/link.svg"; -import LocationErrorSvg from "@/assets/icons/location-error.svg"; import LocationSvg from "@/assets/icons/location.svg"; +import LocationErrorSvg from "@/assets/icons/location-error.svg"; +import MenuDotHorizontalSvg from "@/assets/icons/menu-dot-horizontal.svg"; import MenuDotVerticalSvg from "@/assets/icons/menu-dot-vertical.svg"; import MenuHamburgerSvg from "@/assets/icons/menu-hamburger.svg"; import PlusSvg from "@/assets/icons/plus.svg"; import ReactionStarSvg from "@/assets/icons/reaction-star.svg"; +import RefreshSvg from "@/assets/icons/refresh.svg"; import SavedSvg from "@/assets/icons/saved.svg"; import SearchSvg from "@/assets/icons/search.svg"; import SettingsSvg from "@/assets/icons/settings.svg"; +import SendSvg from "@/assets/icons/send.svg"; import ShareSvg from "@/assets/icons/share.svg"; import TimeClockSvg from "@/assets/icons/time-clock.svg"; import TrashSvg from "@/assets/icons/trash.svg"; +import UserEditSvg from "@/assets/icons/user-edit.svg"; +import UserXSvg from "@/assets/icons/user-x.svg"; import WriteAddSvg from "@/assets/icons/write-add.svg"; import WriteSvg from "@/assets/icons/write.svg"; @@ -34,18 +41,25 @@ export const ChevronLeftIcon = createIcon(ChevronLeftSvg, "ChevronLeftIcon"); export const CircleCancelIcon = createIcon(CircleCancelSvg, "CircleCancelIcon"); export const CloseIcon = createIcon(CloseSvg, "CloseIcon"); export const CoinIcon = createIcon(CoinSvg, "CoinIcon"); +export const CopyCheckIcon = createIcon(CopyCheckSvg, "CopyCheckIcon"); +export const CopyIcon = createIcon(CopySvg, "CopyIcon"); export const LinkIcon = createIcon(LinkSvg, "LinkIcon"); export const LocationErrorIcon = createIcon(LocationErrorSvg, "LocationErrorIcon"); export const LocationIcon = createIcon(LocationSvg, "LocationIcon"); +export const MenuDotHorizontalIcon = createIcon(MenuDotHorizontalSvg, "MenuDotHorizontalIcon"); export const MenuDotVerticalIcon = createIcon(MenuDotVerticalSvg, "MenuDotVerticalIcon"); export const MenuHamburgerIcon = createIcon(MenuHamburgerSvg, "MenuHamburgerIcon"); export const PlusIcon = createIcon(PlusSvg, "PlusIcon"); export const ReactionStarIcon = createIcon(ReactionStarSvg, "ReactionStarIcon"); +export const RefreshIcon = createIcon(RefreshSvg, "RefreshIcon"); export const SavedIcon = createIcon(SavedSvg, "SavedIcon"); export const SearchIcon = createIcon(SearchSvg, "SearchIcon"); export const SettingsIcon = createIcon(SettingsSvg, "SettingsIcon"); +export const SendIcon = createIcon(SendSvg, "SendIcon"); export const ShareIcon = createIcon(ShareSvg, "ShareIcon"); export const TimeClockIcon = createIcon(TimeClockSvg, "TimeClockIcon"); export const TrashIcon = createIcon(TrashSvg, "TrashIcon"); +export const UserEditIcon = createIcon(UserEditSvg, "UserEditIcon"); +export const UserXIcon = createIcon(UserXSvg, "UserXIcon"); export const WriteAddIcon = createIcon(WriteAddSvg, "WriteAddIcon"); export const WriteIcon = createIcon(WriteSvg, "WriteIcon"); diff --git a/src/assets/icons/menu-dot-horizontal.svg b/src/assets/icons/menu-dot-horizontal.svg new file mode 100644 index 00000000..bc421cd9 --- /dev/null +++ b/src/assets/icons/menu-dot-horizontal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/refresh.svg b/src/assets/icons/refresh.svg new file mode 100644 index 00000000..a63d0c79 --- /dev/null +++ b/src/assets/icons/refresh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/send.svg b/src/assets/icons/send.svg new file mode 100644 index 00000000..2fae63b1 --- /dev/null +++ b/src/assets/icons/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/user-edit.svg b/src/assets/icons/user-edit.svg new file mode 100644 index 00000000..cba892b2 --- /dev/null +++ b/src/assets/icons/user-edit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/user-x.svg b/src/assets/icons/user-x.svg new file mode 100644 index 00000000..148990f3 --- /dev/null +++ b/src/assets/icons/user-x.svg @@ -0,0 +1,6 @@ + + + + + + From c79e22ab9bb7d1d86dac1cb92b44401dab0df60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Tue, 6 Oct 2026 22:54:38 +0900 Subject: [PATCH 02/13] =?UTF-8?q?refactor(ui):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=C2=B7=EC=95=84?= =?UTF-8?q?=EB=B0=94=ED=83=80=C2=B7=EB=B0=B0=EC=A7=80=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=20=EC=98=B5=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 - UserAvatar: 배경·이니셜 스타일을 바꿀 className, initialClassName 추가 - SidebarPingBadge: 위치를 덮어쓸 className 추가 - SettingsDialog: medium(600px) 크기, className 병합, 제목 옆 titleAccessory, data-skip-autofocus 첫 포커스 제외 추가 - ConfirmDialog: ledger 설명의 줄바꿈(\n) 표시 - DestinationSearchInput: 48px 입력칸 모양 appearance="field" 추가 (기본 동작 유지) Co-Authored-By: Claude Opus 5.5 --- src/components/layout/SidebarPingBadge.tsx | 7 +++++-- .../search/DestinationSearchInput.tsx | 15 +++++++++---- src/components/settings/ConfirmDialog.tsx | 2 +- src/components/settings/SettingsDialog.tsx | 21 +++++++++++++------ src/components/user/UserAvatar.tsx | 11 +++++++--- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/components/layout/SidebarPingBadge.tsx b/src/components/layout/SidebarPingBadge.tsx index 6b773236..8a759d96 100644 --- a/src/components/layout/SidebarPingBadge.tsx +++ b/src/components/layout/SidebarPingBadge.tsx @@ -1,6 +1,9 @@ -export function SidebarPingBadge() { +import { cn } from "@/lib/utils"; + +/** `className`으로 위치를 덮어쓸 수 있다 — 예: 헤더 아바타 우상단 */ +export function SidebarPingBadge({ className }: { className?: string }) { return ( - + diff --git a/src/components/search/DestinationSearchInput.tsx b/src/components/search/DestinationSearchInput.tsx index 6438ff40..082cf080 100644 --- a/src/components/search/DestinationSearchInput.tsx +++ b/src/components/search/DestinationSearchInput.tsx @@ -29,6 +29,8 @@ type Props = { leadingIconType?: "search" | "map-pin"; /** true면 목록에서만 선택 — 입력·드롭다운 동작 동일하게 한 줄 필드 유지 */ selectionOnly?: boolean; + /** `field`: 48px 테두리 입력칸 모양, 하단 안내 문구 없음 (여행 정보 수정 모달) */ + appearance?: "default" | "field"; }; /** @@ -44,7 +46,10 @@ export function DestinationSearchInput({ showLeadingIcon = true, leadingIconType = "map-pin", selectionOnly = false, + appearance = "default", }: Props) { + const isField = appearance === "field"; + const showHint = selectionOnly && !isField; const placesLib = useMapsLibrary("places"); const buildRequest = useCallback( @@ -189,8 +194,9 @@ export function DestinationSearchInput({
{leadingGlyph} @@ -211,12 +217,13 @@ export function DestinationSearchInput({ "aria-autocomplete": "list" as const, "aria-expanded": showDropdown, "aria-haspopup": "listbox" as const, - "aria-describedby": selectionOnlyFooterId, + "aria-describedby": showHint ? selectionOnlyFooterId : undefined, } as const) : {})} className={cn( "min-w-0 flex-1 text-body-m-regular text-dark-gray outline-none placeholder:text-light-gray", selectionOnly && "bg-transparent", + isField && "text-text-subtle placeholder:text-text-disabled", )} /> {showClear ? ( @@ -264,7 +271,7 @@ export function DestinationSearchInput({ ) : null}
- {selectionOnly ? ( + {showHint ? (

{description ? ( -

+

{description}

) : null} diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index ec9f47a8..2c523a54 100644 --- a/src/components/settings/SettingsDialog.tsx +++ b/src/components/settings/SettingsDialog.tsx @@ -4,6 +4,8 @@ import { useEffect, useId, useRef, type ReactNode, type SyntheticEvent } from "r import { createPortal } from "react-dom"; import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + // 포털의 이벤트가 React 트리의 카드 클릭·드래그 호스트로 전파되는 것만 막는다. // 사용자 동작은 내부 버튼이 담당하며, 키 입력은 document의 포커스·Escape 처리로 전달한다. const stopPropagation = (event: SyntheticEvent) => event.stopPropagation(); @@ -44,16 +46,19 @@ export function SettingsDialog({ className = "", overlayClassName = "", showCloseButton = true, + titleAccessory, }: { title: string; onClose: () => void; children: ReactNode; - size?: "default" | "compact"; + size?: "default" | "medium" | "compact"; appearance?: "default" | "ledger"; stopPortalEventPropagation?: boolean; className?: string; overlayClassName?: string; showCloseButton?: boolean; + /** 제목 바로 옆에 붙는 보조 액션 — 예: 여행 삭제 */ + titleAccessory?: ReactNode; }) { const titleId = useId(); const overlayRef = useRef(null); @@ -75,7 +80,8 @@ export function SettingsDialog({ 'button:not(:disabled), a[href], summary, input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]', ), ]; - focusable()[0]?.focus(); + // 제목 옆 위험 액션 등 `data-skip-autofocus`는 첫 포커스에서 건너뛴다 + (focusable().find((node) => !node.hasAttribute("data-skip-autofocus")) ?? focusable()[0])?.focus(); function handleKey(event: KeyboardEvent) { if (event.defaultPrevented) return; @@ -131,12 +137,15 @@ export function SettingsDialog({ ref={dialogRef} aria-modal="true" aria-labelledby={titleId} - className={`relative m-0 border-0 max-h-[calc(100dvh-2rem)] w-full min-w-0 ${size === "compact" ? "max-w-md" : "max-w-[640px]"} overflow-y-auto overscroll-contain ${appearance === "ledger" ? "rounded-xl mobile:max-w-[353px] max-sm:max-w-[353px]" : "rounded-3xl"} bg-white p-6 text-neutral-900 shadow-xl ${appearance === "ledger" ? "[scrollbar-gutter:auto]" : "[scrollbar-gutter:stable_both-edges] sm:px-8 sm:py-6"} ${className}`} + className={cn(`relative m-0 border-0 max-h-[calc(100dvh-2rem)] w-full min-w-0 ${size === "compact" ? "max-w-md" : size === "medium" ? "max-w-[600px]" : "max-w-[640px]"} overflow-y-auto overscroll-contain ${appearance === "ledger" ? "rounded-xl mobile:max-w-[353px] max-sm:max-w-[353px]" : "rounded-3xl"} bg-white p-6 text-neutral-900 shadow-xl ${appearance === "ledger" ? "[scrollbar-gutter:auto]" : "[scrollbar-gutter:stable_both-edges] sm:px-8 sm:py-6"}`, className)} >
-

- {title} -

+
+

+ {title} +

+ {titleAccessory} +
{showCloseButton && - 멤버 관리 + 초대하기 { dismissFeedbackPrompt(); closeMenu(); }} className="flex min-h-12 items-center gap-2 border-b border-border-subtle px-4 text-[14px] font-medium tracking-[-0.02em] text-text hover:bg-fill active:bg-fill active:bg-fill"> 피드백 @@ -184,7 +185,7 @@ const HeaderBar = ({
-
+
{currentRoom || displayTitle ? ( <> @@ -202,6 +203,7 @@ const HeaderBar = ({ 방 정보 없음 )}
+
)} diff --git a/src/components/layout/HeaderMemberPreview.tsx b/src/components/layout/HeaderMemberPreview.tsx new file mode 100644 index 00000000..c0f7726d --- /dev/null +++ b/src/components/layout/HeaderMemberPreview.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Link from "next/link"; + +import { SendIcon } from "@/assets/icons"; +import { UserAvatar } from "@/components/user/UserAvatar"; +import { useCurrentRoomId } from "@/hooks/use-room-id"; +import { useHostJoinRequestsBadgeCount } from "@/hooks/useHostJoinRequestsBadgeCount"; +import { useRoomMembers } from "@/hooks/useRooms"; +import { useSessionUser } from "@/hooks/useSessionUser"; +import { selectHeaderMemberPreview } from "@/lib/rooms/member-preview"; + +import { SidebarPingBadge } from "./SidebarPingBadge"; + +const MEMBER_SETTINGS_HREF = "/member-settings"; + +/** 헤더 우측 — 온라인 구성원 미리보기(최대 3명 + 나머지 인원)와 초대하기 */ +export function HeaderMemberPreview() { + const { roomId } = useCurrentRoomId(); + const { data: user } = useSessionUser(); + const { data: membersData } = useRoomMembers(roomId); + const pendingJoinRequestsCount = useHostJoinRequestsBadgeCount(); + const { visible, restCount } = selectHeaderMemberPreview( + membersData?.members ?? [], + user?.id, + ); + const memberCount = visible.length + restCount; + + return ( +
+ {memberCount > 0 ? ( + + {visible.map((member, index) => ( + 0 ? "relative -ml-1.5" : "relative"} + > + + + ))} + {restCount > 0 ? ( + + +{restCount} + + ) : null} + {pendingJoinRequestsCount > 0 ? ( + + ) : null} + + ) : null} + 0 ? `초대하기, 참여 요청 ${pendingJoinRequestsCount}건` : "초대하기"} + className="flex items-center gap-1 rounded-sm text-label-m-emphasis text-primary transition-colors hover:text-primary-strong hover:underline hover:underline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + > + + + {pendingJoinRequestsCount > 0 && memberCount === 0 ? ( + + ) : null} + + 초대하기 + +
+ ); +} diff --git a/src/components/layout/SideBar.tsx b/src/components/layout/SideBar.tsx index c899435d..d3bc8075 100644 --- a/src/components/layout/SideBar.tsx +++ b/src/components/layout/SideBar.tsx @@ -3,7 +3,6 @@ import { usePathname } from "next/navigation"; import { useChat } from "@/hooks/useChat"; -import { useHostJoinRequestsBadgeCount } from "@/hooks/useHostJoinRequestsBadgeCount"; import { useCurrentRoomId } from "@/hooks/use-room-id"; import { sidebarWireframeIcons as sidebarIcons, @@ -36,15 +35,9 @@ function isSidebarItemActive(pathname: string, key: string, href: string) { function SideBar() { const pathname = usePathname(); const { chatState, openChat, closeChat } = useChat(); - const pendingJoinRequestsCount = useHostJoinRequestsBadgeCount(); const { roomId } = useCurrentRoomId(); const isChatActive = chatState === "maximized"; - const isOnMemberSettings = pathname.startsWith("/member-settings"); - const isOnRoomSettings = pathname.startsWith("/room-settings"); - const isOnSettingsArea = isOnMemberSettings || isOnRoomSettings; - const showSettingsNotification = - pendingJoinRequestsCount > 0 && !isOnSettingsArea; return (