Skip to content

Commit 7fbb797

Browse files
authored
Merge pull request #51 from CoPlay-FE/feature/Header_collaborator
✨Feat: 공동 사용자 프로필 구현
2 parents 74597e5 + abdd43a commit 7fbb797

File tree

11 files changed

+460
-215
lines changed

11 files changed

+460
-215
lines changed

src/app/dashboard/[id]/edit/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import Header from '@components/common/header/Header'
22

3+
import Sidebar from '@/app/shared/components/common/sidebar/Sidebar'
4+
35
export default function AboutLayout({
46
children,
57
}: {
68
children: React.ReactNode
79
}) {
810
return (
911
<div>
12+
<Sidebar />
1013
<Header />
1114
<div>{children}</div> {/* 여기에 page.tsx 내용이 들어옴 */}
1215
</div>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use client'
2+
3+
import Image from 'next/image'
4+
5+
type AvatarProps = {
6+
nickname: string
7+
imageUrl?: string
8+
size?: number
9+
}
10+
11+
const customColors = [
12+
'#efaa8d',
13+
'#FFC85A',
14+
'#b9ef8d',
15+
'#8eef8d',
16+
'#8defd3',
17+
'#8dcaef',
18+
'#8d9def',
19+
'#a58def',
20+
'#e292e0',
21+
]
22+
23+
function getInitial(nickname: string): string {
24+
const firstChar = nickname.trim().charAt(0)
25+
if (/[a-zA-Z]/.test(firstChar)) return firstChar.toUpperCase()
26+
if (/[-]/.test(firstChar)) return firstChar
27+
return '?'
28+
}
29+
30+
function getColor(nickname: string): string {
31+
const hash = nickname
32+
.split('')
33+
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
34+
return customColors[hash % customColors.length]
35+
}
36+
37+
export function Avatar({ nickname, imageUrl, size = 36 }: AvatarProps) {
38+
const initial = getInitial(nickname)
39+
const bgColor = getColor(nickname)
40+
41+
return imageUrl ? (
42+
<div
43+
className="relative overflow-hidden rounded-full"
44+
style={{ width: size, height: size }}
45+
>
46+
<Image
47+
src={imageUrl}
48+
fill
49+
alt={`${nickname} 프로필 이미지`}
50+
className="object-cover"
51+
/>
52+
</div>
53+
) : (
54+
<div
55+
className="flex items-center justify-center rounded-full font-semibold text-white"
56+
style={{
57+
width: size,
58+
height: size,
59+
fontSize: size * 0.4,
60+
backgroundColor: bgColor,
61+
}}
62+
>
63+
{initial}
64+
</div>
65+
)
66+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
3+
import { cn } from '@lib/cn'
4+
5+
import { Avatar } from './Avatar'
6+
7+
type CollaboratorItemProps = {
8+
nickname: string
9+
imageUrl?: string
10+
size?: number
11+
className?: string
12+
onClick?: () => void
13+
}
14+
15+
export default function CollaboratorItem({
16+
nickname,
17+
imageUrl,
18+
size = 48,
19+
className,
20+
onClick,
21+
}: CollaboratorItemProps) {
22+
return (
23+
<div className={cn('flex items-center gap-3', className)} onClick={onClick}>
24+
<Avatar nickname={nickname} imageUrl={imageUrl} size={size} />
25+
</div>
26+
)
27+
}
Lines changed: 116 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,137 @@
1-
'use client'
2-
3-
import { cn } from '@lib/cn' // 클래스 이름 병합 유틸리티
4-
import { ReactNode, useEffect, useRef, useState } from 'react'
1+
import { useEffect, useRef, useState } from 'react'
2+
import { createPortal } from 'react-dom'
53

4+
// 드롭다운 컴포넌트 타입 정의
65
type DropdownProps = {
7-
trigger: ReactNode
8-
children: ReactNode
9-
align?: 'left' | 'right' | 'center'
10-
width?: string
6+
trigger: React.ReactNode // 드롭다운을 열기 위한 트리거 요소 (버튼, 아이콘 등)
7+
children: React.ReactNode // 드롭다운 내부 콘텐츠 (메뉴 아이템 등)
8+
width?: string // Tailwind 클래스 기반의 너비 설정 (예: 'w-5', 'w-6')
9+
align?: 'left' | 'center' | 'right' // 드롭다운 정렬 방향
1110
}
1211

12+
// 드롭다운 컴포넌트 정의
1313
export default function Dropdown({
1414
trigger,
1515
children,
16-
align = 'center',
17-
width = 'w-40',
16+
width = 'w-6', // 기본 너비 설정
17+
align = 'left', // 기본 정렬 방향 설정
1818
}: DropdownProps) {
19-
const [isOpen, setIsOpen] = useState(false)
20-
const ref = useRef<HTMLDivElement>(null)
19+
const [open, setOpen] = useState(false) // 드롭다운 열림 여부 상태
20+
const triggerRef = useRef<HTMLDivElement>(null) // 반응형에 따른 위치 조정을 위한 ref 객체
21+
const menuRef = useRef<HTMLDivElement>(null) // 외부 클릭 감지를 위한 드롭다운 메뉴 ref 객체
22+
const [coords, setCoords] = useState<{ top: number; left: number }>({
23+
top: 0,
24+
left: 0,
25+
}) // 드롭다운 메뉴 위치 좌표 상태
26+
27+
// 드롭다운 열기/닫기 토글
28+
const toggleOpen = () => setOpen((prev) => !prev)
29+
30+
// Tailwind width 클래스 값을 실제 CSS 너비 값으로 변환
31+
const getWidthValue = (width: string) => {
32+
switch (width) {
33+
case 'w-5': // 할 일 카드 모달에서 사용
34+
return '5rem'
35+
case 'w-6':
36+
return '6rem'
37+
default:
38+
return undefined // 정의되지 않은 값은 기본 width 적용
39+
}
40+
}
2141

42+
// 드롭다운이 열릴 때 위치 계산 및 윈도우 이벤트 바인딩
2243
useEffect(() => {
23-
const handleClickOutside = (e: MouseEvent) => {
24-
if (ref.current && !ref.current.contains(e.target as Node)) {
25-
setIsOpen(false)
44+
function updateCoords() {
45+
if (open && triggerRef.current) {
46+
const rect = triggerRef.current.getBoundingClientRect() // 트리거 위치 측정
47+
48+
let left = rect.left
49+
if (align === 'center') left = rect.left + rect.width / 2
50+
else if (align === 'right') left = rect.right
51+
52+
setCoords({
53+
top: rect.bottom + window.scrollY, // 화면 스크롤 반영
54+
left,
55+
})
2656
}
2757
}
2858

29-
document.addEventListener('mousedown', handleClickOutside)
30-
return () => document.removeEventListener('mousedown', handleClickOutside)
31-
}, [])
59+
if (open) {
60+
updateCoords()
61+
window.addEventListener('resize', updateCoords)
62+
window.addEventListener('scroll', updateCoords)
63+
}
64+
65+
return () => {
66+
window.removeEventListener('resize', updateCoords)
67+
window.removeEventListener('scroll', updateCoords)
68+
}
69+
}, [open, align])
70+
71+
// 드롭다운 외부 클릭 시 닫기
72+
useEffect(() => {
73+
function handleClickOutside(event: MouseEvent) {
74+
if (
75+
menuRef.current &&
76+
!menuRef.current.contains(event.target as Node) &&
77+
triggerRef.current &&
78+
!triggerRef.current.contains(event.target as Node)
79+
) {
80+
setOpen(false)
81+
}
82+
}
3283

84+
if (open) {
85+
document.addEventListener('mousedown', handleClickOutside)
86+
} else {
87+
document.removeEventListener('mousedown', handleClickOutside)
88+
}
89+
90+
return () => {
91+
document.removeEventListener('mousedown', handleClickOutside)
92+
}
93+
}, [open])
94+
95+
// 드롭다운 메뉴 렌더링 (포탈을 이용하여 body로 위치)
96+
const menu = open
97+
? createPortal(
98+
<div
99+
ref={menuRef}
100+
style={{
101+
position: 'absolute',
102+
top: coords.top,
103+
left: coords.left,
104+
transform:
105+
align === 'center'
106+
? 'translateX(-50%)'
107+
: align === 'right'
108+
? 'translateX(-100%)'
109+
: undefined,
110+
width: getWidthValue(width),
111+
minWidth: getWidthValue(width),
112+
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', // 그림자
113+
borderRadius: 8, // 모서리 둥글게
114+
zIndex: 9999, // 위로 띄우기
115+
}}
116+
className="BG-white" // 커스텀 배경 클래스 (Tailwind 예시)
117+
>
118+
{children}
119+
</div>,
120+
document.body, // body에 포탈로 삽입
121+
)
122+
: null
123+
124+
// 트리거 요소 + 드롭다운 메뉴 렌더링
33125
return (
34-
<div ref={ref} className="relative inline-block">
126+
<>
35127
<div
36-
onClick={() => setIsOpen((prev) => !prev)}
37-
className="cursor-pointer"
128+
ref={triggerRef}
129+
onClick={toggleOpen}
130+
className="inline-block cursor-pointer"
38131
>
39132
{trigger}
40133
</div>
41-
42-
{isOpen && (
43-
<div
44-
className={cn(
45-
'BG-gray absolute z-50 mt-4 rounded border shadow-md',
46-
width,
47-
{
48-
'left-0': align === 'left',
49-
'right-0': align === 'right',
50-
'left-1/2 -translate-x-1/2': align === 'center',
51-
},
52-
)}
53-
>
54-
{children}
55-
</div>
56-
)}
57-
</div>
134+
{menu}
135+
</>
58136
)
59137
}

src/app/shared/components/common/Profile.tsx

Lines changed: 0 additions & 80 deletions
This file was deleted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use client'
2+
3+
import { Avatar } from './Avatar'
4+
5+
type UserInfoProps = {
6+
nickname: string
7+
imageUrl?: string
8+
size?: number
9+
}
10+
11+
export function UserInfo({ nickname, imageUrl, size = 36 }: UserInfoProps) {
12+
return (
13+
<div className="flex items-center gap-4">
14+
<Avatar nickname={nickname} imageUrl={imageUrl} size={size} />
15+
<span className="text-sm font-semibold">{nickname}</span>
16+
</div>
17+
)
18+
}

0 commit comments

Comments
 (0)