Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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>
)
}
147 changes: 110 additions & 37 deletions src/app/shared/components/common/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,132 @@
'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'
trigger: React.ReactNode
children: React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
}

export default function Dropdown({
trigger,
children,
align = 'center',
width = 'w-40',
width = 'w-48',
align = 'left',
}: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [coords, setCoords] = useState<{ top: number; left: number }>({
top: 0,
left: 0,
})

const toggleOpen = () => setOpen((prev) => !prev)

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
}
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

getWidthValue가 대부분의 width 프리셋을 처리하지 못합니다

현재 'w-48' (default) 등 Tailwind 클래스를 전달하면 반환값이 undefined가 되어 드롭다운 너비가 의도치않게 내용물에 종속됩니다. 필요 프리셋을 모두 매핑하거나, 직접 rem 값을 받도록 단순화하는 편이 안전합니다.

🤖 Prompt for AI Agents
In src/app/shared/components/common/Dropdown/Dropdown.tsx between lines 27 and
39, the getWidthValue function only handles a few width presets and returns
undefined for most Tailwind width classes like 'w-48', causing the dropdown
width to depend on content unexpectedly. To fix this, either extend the switch
statement to cover all necessary Tailwind width classes with their corresponding
rem values or simplify the function to accept direct rem values as input,
ensuring the dropdown width is always set correctly.


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])

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"
>
{children}
</div>,
document.body,
)
: 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