-
Notifications
You must be signed in to change notification settings - Fork 2
✨Feat: 공동 사용자 프로필 구현 #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
eb5ff25
2934c90
41c3d01
e1a156c
13e312b
340b393
a9e94f7
62e7eb2
723a5ce
d62ead4
c71cafe
e306e19
428ffc2
6b1ec14
7e1c7ff
956fb2a
03c1917
abdd43a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ) | ||
| } |
| 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> | ||
| ) | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tailwind 스케일과 불일치한 rem 매핑
- 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||
| return undefined | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion
현재 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 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} | ||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
This file was deleted.
| 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> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
콘텐츠가 사이드바 아래로 겹칠 가능성
Sidebar가fixed left-0 w-300으로 배치되는데, 레이아웃 컨테이너에 좌측 패딩이 없어 본문이 가려질 수 있습니다.같은 방식으로 오프셋을 주거나 CSS Grid 레이아웃을 권장합니다.
📝 Committable suggestion
🤖 Prompt for AI Agents