Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
eb5ff25
✨feat: 재사용성을 위해 사용자 프로필과 닉네임 분리
yuj2n Jun 13, 2025
2934c90
✨feat: 공동 작업자 목록 컴포넌트 구현
yuj2n Jun 13, 2025
41c3d01
✨feat: 한 명의 공동 작업자 아바타 렌더링 구현
yuj2n Jun 13, 2025
e1a156c
✨feat: 사이드바와 연결 및 공동 작업자 프로필 이미지 삽입
yuj2n Jun 13, 2025
13e312b
🫧modify: 바뀐 사용자 정보 컴포넌트 변경
yuj2n Jun 13, 2025
340b393
🫧modify: 테스트 페이지에 헤더 적용
yuj2n Jun 13, 2025
a9e94f7
✨feat: 대시보드 수정 페이지에 사이드바 적용
yuj2n Jun 13, 2025
62e7eb2
✨feat: 공동 작업자 프로필 툴팁 컴포넌트 구현
yuj2n Jun 13, 2025
723a5ce
✨feat: 공동 작업자 프로필에 툴팁 적용
yuj2n Jun 13, 2025
d62ead4
📁chore: 폴더 구조 변경
yuj2n Jun 13, 2025
c71cafe
🎨style: 헤더 반응형 대비 스타일 수정
yuj2n Jun 13, 2025
e306e19
🎨style: 헤더에 다크모드 배경색 적용을 위한 스타일 추가
yuj2n Jun 13, 2025
428ffc2
✨feat: 반응형으로 툴팁이 보이지 않던 문제 Portal 적용으로 해결
yuj2n Jun 13, 2025
6b1ec14
🫧modify: 컴포넌트 구분을 위한 주석 추가
yuj2n Jun 13, 2025
7e1c7ff
🐛fix: React의 createPortal API로 드롭다운 툴팁 가려지는 문제 수정
yuj2n Jun 13, 2025
956fb2a
✨feat: 반응형에 따른 트리거 위치 계산을 위한 ref 객체 추가
yuj2n Jun 13, 2025
03c1917
🎨style: 드롭다운 디자인 수정
yuj2n Jun 13, 2025
abdd43a
🫧modify: 코드 이해를 위한 주석 추가
yuj2n Jun 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/app/dashboard/[id]/edit/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import Header from '@components/common/header/Header'

import Sidebar from '@/app/shared/components/common/sidebar/Sidebar'

export default function AboutLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<Sidebar />
<Header />
<div>{children}</div> {/* 여기에 page.tsx 내용이 들어옴 */}
</div>
Comment on lines 11 to 15
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

콘텐츠가 사이드바 아래로 겹칠 가능성

Sidebarfixed left-0 w-300으로 배치되는데, 레이아웃 컨테이너에 좌측 패딩이 없어 본문이 가려질 수 있습니다.

- <div>
+ <div className="pl-300">

같은 방식으로 오프셋을 주거나 CSS Grid 레이아웃을 권장합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div>
<Sidebar />
<Header />
<div>{children}</div> {/* 여기에 page.tsx 내용이 들어옴 */}
</div>
<div className="pl-300">
<Sidebar />
<Header />
<div>{children}</div> {/* 여기에 page.tsx 내용이 들어옴 */}
</div>
🤖 Prompt for AI Agents
In src/app/dashboard/[id]/edit/layout.tsx around lines 11 to 15, the main
content div does not have left padding or margin to offset the fixed Sidebar,
causing the content to be overlapped by the Sidebar. To fix this, add left
padding or margin to the container wrapping the children equal to the Sidebar's
width, or refactor the layout to use CSS Grid to properly allocate space for the
Sidebar and main content.

Expand Down
66 changes: 66 additions & 0 deletions src/app/shared/components/common/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<div
className="relative overflow-hidden rounded-full"
style={{ width: size, height: size }}
>
<Image
src={imageUrl}
fill
alt={`${nickname} 프로필 이미지`}
className="object-cover"
/>
</div>
) : (
<div
className="flex items-center justify-center rounded-full font-semibold text-white"
style={{
width: size,
height: size,
fontSize: size * 0.4,
backgroundColor: bgColor,
}}
>
{initial}
</div>
)
}
27 changes: 27 additions & 0 deletions src/app/shared/components/common/CollaboratorItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn('flex items-center gap-3', className)} onClick={onClick}>
<Avatar nickname={nickname} imageUrl={imageUrl} size={size} />
</div>
)
}
154 changes: 116 additions & 38 deletions src/app/shared/components/common/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const [open, setOpen] = useState(false) // 드롭다운 열림 여부 상태
const triggerRef = useRef<HTMLDivElement>(null) // 반응형에 따른 위치 조정을 위한 ref 객체
const menuRef = useRef<HTMLDivElement>(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'
Comment on lines +34 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Tailwind 스케일과 불일치한 rem 매핑

'w-6' → 1.5 rem이 맞지만 6 rem으로 잘못 매핑되어 UI 가로폭이 4배 이상 넓어집니다. 숫자를 직접 변환하도록 로직을 일반화하세요.

-      case 'w-6':
-        return '6rem'
+      case 'w-6':
+        return '1.5rem'
+
+      // tailwind `w-{n}` => n*0.25rem 기본 스케일 처리 예시
+      default: {
+        const match = width.match(/^w-(\d+)$/)
+        if (match) return `${Number(match[1]) * 0.25}rem`
+        return undefined
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return '5rem'
case 'w-6':
return '6rem'
return '5rem'
case 'w-6':
return '1.5rem'
// tailwind `w-{n}` => n*0.25rem 기본 스케일 처리 예시
default: {
const match = width.match(/^w-(\d+)$/)
if (match) return `${Number(match[1]) * 0.25}rem`
return undefined
}
🤖 Prompt for AI Agents
In src/app/shared/components/common/Dropdown/Dropdown.tsx around lines 34 to 36,
the mapping from Tailwind width classes to rem values is incorrect, specifically
'w-6' is mapped to '6rem' instead of the correct '1.5rem'. Fix this by
generalizing the logic to convert the numeric part of the class (e.g., 6) to rem
by multiplying it by 0.25, ensuring all width classes map correctly to their
Tailwind rem equivalents.

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)
}
Comment on lines +71 to +88
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

모바일 환경 외부 터치 감지 누락

mousedown만 등록되어 터치 디바이스에서 닫힘이 동작하지 않습니다. touchstart도 함께 바인딩해 주세요.

-      document.addEventListener('mousedown', handleClickOutside)
+      document.addEventListener('mousedown', handleClickOutside)
+      document.addEventListener('touchstart', handleClickOutside, { passive: true })
 ...
-      document.removeEventListener('mousedown', handleClickOutside)
+      document.removeEventListener('mousedown', handleClickOutside)
+      document.removeEventListener('touchstart', handleClickOutside)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 드롭다운 외부 클릭 시 닫기
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)
}
// 드롭다운 외부 클릭 시 닫기
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)
document.addEventListener('touchstart', handleClickOutside, { passive: true })
} else {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('touchstart', handleClickOutside)
}
}, [open])
🤖 Prompt for AI Agents
In src/app/shared/components/common/Dropdown/Dropdown.tsx around lines 71 to 88,
the event listener only listens for 'mousedown' events, which causes the
dropdown not to close on touch devices. To fix this, add an event listener for
'touchstart' alongside 'mousedown' when the dropdown is open, and remove both
listeners when it is closed, ensuring touch interactions also trigger the close
behavior.


return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [open])

// 드롭다운 메뉴 렌더링 (포탈을 이용하여 body로 위치)
const menu = open
? createPortal(
<div
ref={menuRef}
style={{
position: 'absolute',
top: coords.top,
left: coords.left,
transform:
align === 'center'
? 'translateX(-50%)'
: align === 'right'
? 'translateX(-100%)'
: undefined,
width: getWidthValue(width),
minWidth: getWidthValue(width),
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', // 그림자
borderRadius: 8, // 모서리 둥글게
zIndex: 9999, // 위로 띄우기
}}
className="BG-white" // 커스텀 배경 클래스 (Tailwind 예시)
>
{children}
</div>,
document.body, // body에 포탈로 삽입
)
Comment on lines +96 to +121

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createPortal이 어떤 역할을 해주는 건가요?

: null

// 트리거 요소 + 드롭다운 메뉴 렌더링
return (
<div ref={ref} className="relative inline-block">
<>
<div
onClick={() => setIsOpen((prev) => !prev)}
className="cursor-pointer"
ref={triggerRef}
onClick={toggleOpen}
className="inline-block cursor-pointer"
>
{trigger}
</div>

{isOpen && (
<div
className={cn(
'BG-gray absolute z-50 mt-4 rounded border shadow-md',
width,
{
'left-0': align === 'left',
'right-0': align === 'right',
'left-1/2 -translate-x-1/2': align === 'center',
},
)}
>
{children}
</div>
)}
</div>
{menu}
</>
)
}
80 changes: 0 additions & 80 deletions src/app/shared/components/common/Profile.tsx

This file was deleted.

18 changes: 18 additions & 0 deletions src/app/shared/components/common/UserInfo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-4">
<Avatar nickname={nickname} imageUrl={imageUrl} size={size} />
<span className="text-sm font-semibold">{nickname}</span>
</div>
)
}
Loading