diff --git a/src/app/dashboard/[id]/edit/layout.tsx b/src/app/dashboard/[id]/edit/layout.tsx index 123e37a..a991b87 100644 --- a/src/app/dashboard/[id]/edit/layout.tsx +++ b/src/app/dashboard/[id]/edit/layout.tsx @@ -1,5 +1,7 @@ import Header from '@components/common/header/Header' +import Sidebar from '@/app/shared/components/common/sidebar/Sidebar' + export default function AboutLayout({ children, }: { @@ -7,6 +9,7 @@ export default function AboutLayout({ }) { return (
+
{children}
{/* 여기에 page.tsx 내용이 들어옴 */}
diff --git a/src/app/shared/components/common/Avatar.tsx b/src/app/shared/components/common/Avatar.tsx new file mode 100644 index 0000000..e87b21d --- /dev/null +++ b/src/app/shared/components/common/Avatar.tsx @@ -0,0 +1,66 @@ +'use client' + +import Image from 'next/image' + +type AvatarProps = { + nickname: string + imageUrl?: string + size?: number +} + +const customColors = [ + '#efaa8d', + '#FFC85A', + '#b9ef8d', + '#8eef8d', + '#8defd3', + '#8dcaef', + '#8d9def', + '#a58def', + '#e292e0', +] + +function getInitial(nickname: string): string { + const firstChar = nickname.trim().charAt(0) + if (/[a-zA-Z]/.test(firstChar)) return firstChar.toUpperCase() + if (/[가-힣]/.test(firstChar)) return firstChar + return '?' +} + +function getColor(nickname: string): string { + const hash = nickname + .split('') + .reduce((acc, char) => acc + char.charCodeAt(0), 0) + return customColors[hash % customColors.length] +} + +export function Avatar({ nickname, imageUrl, size = 36 }: AvatarProps) { + const initial = getInitial(nickname) + const bgColor = getColor(nickname) + + return imageUrl ? ( +
+ {`${nickname} +
+ ) : ( +
+ {initial} +
+ ) +} diff --git a/src/app/shared/components/common/CollaboratorItem.tsx b/src/app/shared/components/common/CollaboratorItem.tsx new file mode 100644 index 0000000..cc65c49 --- /dev/null +++ b/src/app/shared/components/common/CollaboratorItem.tsx @@ -0,0 +1,27 @@ +'use client' + +import { cn } from '@lib/cn' + +import { Avatar } from './Avatar' + +type CollaboratorItemProps = { + nickname: string + imageUrl?: string + size?: number + className?: string + onClick?: () => void +} + +export default function CollaboratorItem({ + nickname, + imageUrl, + size = 48, + className, + onClick, +}: CollaboratorItemProps) { + return ( +
+ +
+ ) +} diff --git a/src/app/shared/components/common/Dropdown/Dropdown.tsx b/src/app/shared/components/common/Dropdown/Dropdown.tsx index 5dbd836..e268e80 100644 --- a/src/app/shared/components/common/Dropdown/Dropdown.tsx +++ b/src/app/shared/components/common/Dropdown/Dropdown.tsx @@ -1,59 +1,137 @@ -'use client' - -import { cn } from '@lib/cn' // 클래스 이름 병합 유틸리티 -import { ReactNode, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +// 드롭다운 컴포넌트 타입 정의 type DropdownProps = { - trigger: ReactNode - children: ReactNode - align?: 'left' | 'right' | 'center' - width?: string + trigger: React.ReactNode // 드롭다운을 열기 위한 트리거 요소 (버튼, 아이콘 등) + children: React.ReactNode // 드롭다운 내부 콘텐츠 (메뉴 아이템 등) + width?: string // Tailwind 클래스 기반의 너비 설정 (예: 'w-5', 'w-6') + align?: 'left' | 'center' | 'right' // 드롭다운 정렬 방향 } +// 드롭다운 컴포넌트 정의 export default function Dropdown({ trigger, children, - align = 'center', - width = 'w-40', + width = 'w-6', // 기본 너비 설정 + align = 'left', // 기본 정렬 방향 설정 }: DropdownProps) { - const [isOpen, setIsOpen] = useState(false) - const ref = useRef(null) + const [open, setOpen] = useState(false) // 드롭다운 열림 여부 상태 + const triggerRef = useRef(null) // 반응형에 따른 위치 조정을 위한 ref 객체 + const menuRef = useRef(null) // 외부 클릭 감지를 위한 드롭다운 메뉴 ref 객체 + const [coords, setCoords] = useState<{ top: number; left: number }>({ + top: 0, + left: 0, + }) // 드롭다운 메뉴 위치 좌표 상태 + + // 드롭다운 열기/닫기 토글 + const toggleOpen = () => setOpen((prev) => !prev) + + // Tailwind width 클래스 값을 실제 CSS 너비 값으로 변환 + const getWidthValue = (width: string) => { + switch (width) { + case 'w-5': // 할 일 카드 모달에서 사용 + return '5rem' + case 'w-6': + return '6rem' + default: + return undefined // 정의되지 않은 값은 기본 width 적용 + } + } + // 드롭다운이 열릴 때 위치 계산 및 윈도우 이벤트 바인딩 useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - setIsOpen(false) + function updateCoords() { + if (open && triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() // 트리거 위치 측정 + + let left = rect.left + if (align === 'center') left = rect.left + rect.width / 2 + else if (align === 'right') left = rect.right + + setCoords({ + top: rect.bottom + window.scrollY, // 화면 스크롤 반영 + left, + }) } } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + if (open) { + updateCoords() + window.addEventListener('resize', updateCoords) + window.addEventListener('scroll', updateCoords) + } + + return () => { + window.removeEventListener('resize', updateCoords) + window.removeEventListener('scroll', updateCoords) + } + }, [open, align]) + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + triggerRef.current && + !triggerRef.current.contains(event.target as Node) + ) { + setOpen(false) + } + } + if (open) { + document.addEventListener('mousedown', handleClickOutside) + } else { + document.removeEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [open]) + + // 드롭다운 메뉴 렌더링 (포탈을 이용하여 body로 위치) + const menu = open + ? createPortal( +
+ {children} +
, + document.body, // body에 포탈로 삽입 + ) + : null + + // 트리거 요소 + 드롭다운 메뉴 렌더링 return ( -
+ <>
setIsOpen((prev) => !prev)} - className="cursor-pointer" + ref={triggerRef} + onClick={toggleOpen} + className="inline-block cursor-pointer" > {trigger}
- - {isOpen && ( -
- {children} -
- )} -
+ {menu} + ) } diff --git a/src/app/shared/components/common/Profile.tsx b/src/app/shared/components/common/Profile.tsx deleted file mode 100644 index ece3024..0000000 --- a/src/app/shared/components/common/Profile.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client' - -import Image from 'next/image' - -type ProfileProps = { - nickname: string - imageUrl?: string - size?: number -} - -const customColors = [ - '#efaa8d', - '#FFC85A', - '#b9ef8d', - '#8eef8d', - '#8defd3', - '#8dcaef', - '#8d9def', - '#a58def', - '#e292e0', -] - -// 첫 글자로 사용자 프로필 생성하는 함수 -function getInitial(nickname: string): string { - const firstChar = nickname.trim().charAt(0) - - if (/[a-zA-Z]/.test(firstChar)) { - return firstChar.toUpperCase() // 영어: 대문자 - } - - if (/[가-힣]/.test(firstChar)) { - return firstChar // 한글은 그대로 반환 - } - - return '?' // 기타문자: 물음표 -} - -// 닉네임으로부터 배경색 생성 함수 -function getColor(nickname: string): string { - const hash = nickname - .split('') - .reduce((acc, char) => acc + char.charCodeAt(0), 0) - return customColors[hash % customColors.length] -} - -export function Profile({ nickname, imageUrl, size = 36 }: ProfileProps) { - const initial = getInitial(nickname) - const bgColor = getColor(nickname) - - return imageUrl ? ( - // 프로필 이미지가 있을 때 -
-
- 프로필 이미지 -
- {nickname} -
- ) : ( - // 프로필 이미지가 없을 때 - <> -
- {initial} -
-
{nickname}
- - ) -} diff --git a/src/app/shared/components/common/UserInfo.tsx b/src/app/shared/components/common/UserInfo.tsx new file mode 100644 index 0000000..f4a06c9 --- /dev/null +++ b/src/app/shared/components/common/UserInfo.tsx @@ -0,0 +1,18 @@ +'use client' + +import { Avatar } from './Avatar' + +type UserInfoProps = { + nickname: string + imageUrl?: string + size?: number +} + +export function UserInfo({ nickname, imageUrl, size = 36 }: UserInfoProps) { + return ( +
+ + {nickname} +
+ ) +} diff --git a/src/app/shared/components/common/header/Collaborator/CollaboratorList.tsx b/src/app/shared/components/common/header/Collaborator/CollaboratorList.tsx new file mode 100644 index 0000000..1543cf6 --- /dev/null +++ b/src/app/shared/components/common/header/Collaborator/CollaboratorList.tsx @@ -0,0 +1,61 @@ +'use client' // Tooltip, CollaboratorItem 컴포넌트는 클라이언트에서만 사용되므로 'use client' 선언 + +import CollaboratorItem from '../../CollaboratorItem' +import Tooltip from './Tooltip' // 툴팁 기능 + +// 임시 mock 협업자 데이터 +export const mockCollaborators = [ + { nickname: '홍길동', imageUrl: '/images/collaborator.png' }, + { nickname: '김철수', imageUrl: '/images/collaborator.png' }, + { nickname: '이영희', imageUrl: '/images/collaborator.png' }, + { nickname: '뚜비', imageUrl: '/images/collaborator.png' }, + { nickname: '두룹', imageUrl: '/images/collaborator.png' }, + { nickname: 'ㅋ', imageUrl: '/images/collaborator.png' }, +] + +// 협업자 배열을 선택적으로 전달받음 +type CollaboratorListProps = { + collaborators?: { + nickname: string + imageUrl?: string + }[] +} + +// 협업자 리스트 컴포넌트 +export default function CollaboratorList({ + collaborators = mockCollaborators, // 전달된 협업자가 없을 경우 mock 데이터 사용 +}: CollaboratorListProps) { + const MAX_VISIBLE = 4 // 최대 표시 협업자 수 + const visibleCollaborators = collaborators.slice(0, MAX_VISIBLE) // 앞에서부터 4명만 추출 + const extraCount = collaborators.length - MAX_VISIBLE // 초과 인원 계산 + + return ( +
+ {/* 협업자들 가로 배치 & 간격 설정 */} + {visibleCollaborators.map((collab) => ( + // 각 협업자에 툴팁을 감싸서 닉네임 표시 + +
+ +
+
+ ))} + {/* 초과 인원이 있을 경우 +N 표시 */} + {extraCount > 0 && ( + +
+
+ +{extraCount} {/* 초과 인원 수 출력 */} +
+
+
+ )} +
+ ) +} diff --git a/src/app/shared/components/common/header/Collaborator/Tooltip.tsx b/src/app/shared/components/common/header/Collaborator/Tooltip.tsx new file mode 100644 index 0000000..6b5a787 --- /dev/null +++ b/src/app/shared/components/common/header/Collaborator/Tooltip.tsx @@ -0,0 +1,70 @@ +'use client' // useState, useEffect, createPortal 등의 사용 + +import { ReactNode, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' // 툴팁을 body에 직접 렌더링하기 위한 React API + +// Tooltip 컴포넌트의 props 타입 정의 +type TooltipProps = { + content: string // 툴팁에 표시할 텍스트 + children: ReactNode // 툴팁이 감쌀 자식 요소 +} + +export default function Tooltip({ content, children }: TooltipProps) { + const [visible, setVisible] = useState(false) // 툴팁 표시 여부 상태 + const [coords, setCoords] = useState<{ left: number; top: number }>({ + left: 0, + top: 0, + }) // 툴팁의 위치 상태 + const wrapperRef = useRef(null) // 기준 요소(마우스를 올리는 대상)에 대한 참조 + + // 툴팁이 보일 때 위치 계산 + useEffect(() => { + if (visible && wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect() + setCoords({ + left: rect.left + rect.width / 2, // 가운데 정렬을 위해 요소의 중앙 X 좌표 + top: rect.bottom + window.scrollY, // 요소의 하단 Y 좌표 + 스크롤 보정 + }) + } + }, [visible]) // visible이 변경될 때마다 위치 재계산 + + // 툴팁 콘텐츠를 Portal로 body에 렌더링 + const tooltipContent = visible + ? createPortal( +
+ {content} +
, + document.body, // body에 직접 렌더링 (z-index 문제 방지 등) + ) + : null // 툴팁이 보이지 않을 때는 null 반환 + + return ( +
setVisible(true)} // 마우스 올리면 툴팁 표시 + onMouseLeave={() => setVisible(false)} // 마우스 벗어나면 툴팁 숨김 + > + {children} {/* 툴팁이 감싸는 실제 요소 */} + {tooltipContent} {/* 포탈로 렌더링된 툴팁 */} +
+ ) +} diff --git a/src/app/shared/components/common/header/Header.tsx b/src/app/shared/components/common/header/Header.tsx index c7ebc89..78f6e3e 100644 --- a/src/app/shared/components/common/header/Header.tsx +++ b/src/app/shared/components/common/header/Header.tsx @@ -6,64 +6,59 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import ThemeToggle from '../../ThemeToggle' +import CollaboratorList from './Collaborator/CollaboratorList' import UserDropdown from './UserDropdown' export default function Header() { const pathname = usePathname() return ( -
- {/* 좌측 대시보드명 */} -
-
대시보드 명
-
- 내가 만든 대시보드 +
+
+ {/* 좌측 대시보드명 */} +
+
대시보드 명
+
+ 내가 만든 대시보드 +
-
- {/* 우측 사용자 정보/다크모드 */} -
- - {/* 공동작업자 프로필 이미지 */} -
- 초대된 사용자 -
- | -
- {/* 드롭다운 메뉴 */} - - {/* 다크모드 토글 버튼 */} - + {/* 우측 사용자 정보/다크모드 */} +
+ + {/* 협업자 목록 */} + | +
+ {/* 사용자 정보 드롭다운 */} + + {/* 다크모드 토글 */} + +
diff --git a/src/app/shared/components/common/header/UserDropdown.tsx b/src/app/shared/components/common/header/UserDropdown.tsx index 361be56..b38f5ea 100644 --- a/src/app/shared/components/common/header/UserDropdown.tsx +++ b/src/app/shared/components/common/header/UserDropdown.tsx @@ -1,9 +1,10 @@ 'use client' import Dropdown from '@components/common/Dropdown/Dropdown' -import { Profile } from '@components/common/Profile' import { useRouter } from 'next/navigation' +import { UserInfo } from '../UserInfo' + export default function UserDropdown() { const router = useRouter() @@ -19,21 +20,21 @@ export default function UserDropdown() { - +
} - width="w-80" - align="right" + width="w-6" + align="center" > diff --git a/src/app/tester/page.tsx b/src/app/tester/page.tsx index eae818f..238b229 100644 --- a/src/app/tester/page.tsx +++ b/src/app/tester/page.tsx @@ -1,5 +1,6 @@ import Image from 'next/image' +import Header from '@/app/shared/components/common/header/Header' import Sidebar from '@/app/shared/components/common/sidebar/Sidebar' import ThemeToggle from '@/app/shared/components/ThemeToggle' @@ -18,56 +19,61 @@ import ThemeToggle from '@/app/shared/components/ThemeToggle' export default function Home() { return ( -
- {/* 사이드바 */} - + <> +
+
+ {/* 사이드바 */} + - {/* 메인 콘텐츠 영역 */} -
- {/* 헤더 영역 */} -
-

Sidebar 테스트 페이지

-

왼쪽에 사이드바 만들어보자잇!

- -
- - {/* 기존 테스트 요소들 */} -
-
-

로고 테스트

-
- Logo -
+ {/* 메인 콘텐츠 영역 */} +
+ {/* 헤더 영역 */} +
+

Sidebar 테스트 페이지

+

왼쪽에 사이드바 만들어보자잇!

+
- {/* pxr 단위 테스트 */} -
-

pxr 단위 테스트

-
-

This text should be 16px (일반 px 단위)

+ {/* 기존 테스트 요소들 */} +
+
+

로고 테스트

+
+ Logo +
-
-

This text should be 1rem → converted 16 to 1rem: using pxr

+ + {/* pxr 단위 테스트 */} +
+

pxr 단위 테스트

+
+

This text should be 16px (일반 px 단위)

+
+
+

+ This text should be 1rem → converted 16 to 1rem: using pxr +

+
-
- {/* Gap 테스트 */} -
-

Gap 테스트

-
-
AAA
-
BBB
-
CCC
+ {/* Gap 테스트 */} +
+

Gap 테스트

+
+
AAA
+
BBB
+
CCC
+
-
+ ) }