Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 64 additions & 0 deletions src/features/follow-user/ui/FollowButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as React from 'react'

import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/utils'

type FollowButtonProps = {
className?: string
defaultIsFollowing?: boolean
onToggle?: (nextIsFollowing: boolean) => Promise<void> | void
}

const FOLLOW_BUTTON_LABEL = {
follow: '팔로우',
following: '팔로잉',
}

const FOLLOW_BUTTON_PRIMARY_CLASSNAME =
'bg-[#0095f6] text-white hover:bg-[#0095f6]/90'

export function FollowButton({
className,
defaultIsFollowing = false,
onToggle,
}: FollowButtonProps) {
const [isFollowing, setIsFollowing] = React.useState(defaultIsFollowing)
const [isPending, startTransition] = React.useTransition()

const handleClick = () => {
const nextIsFollowing = !isFollowing

startTransition(() => {
void (async () => {
try {
await onToggle?.(nextIsFollowing)
setIsFollowing(nextIsFollowing)
} catch {
return
}
})()
})
}

const label = isFollowing
? FOLLOW_BUTTON_LABEL.following
: FOLLOW_BUTTON_LABEL.follow

return (
<Button
type="button"
aria-pressed={isFollowing}
disabled={isPending}
onClick={handleClick}
className={cn(
'h-8 rounded-md px-4 text-sm font-semibold',
isFollowing
? 'border border-gray-300 bg-white text-gray-900 hover:bg-gray-50'
: FOLLOW_BUTTON_PRIMARY_CLASSNAME,
className
)}
>
{label}
</Button>
)
}
5 changes: 5 additions & 0 deletions src/shared/lib/numberFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const LOCALE_KO_KR = 'ko-KR'

export function numberFormat(value: number, locale: string = LOCALE_KO_KR) {
return value.toLocaleString(locale)
}
34 changes: 34 additions & 0 deletions src/widgets/profile-header/ui/ProfileAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { cn } from '@/shared/lib/utils'

import { AVATAR_SIZE_CLASSNAME } from './constants'

type ProfileAvatarProps = {
avatarUrl?: string | null
nickname: string
}

export function ProfileAvatar({ avatarUrl, nickname }: ProfileAvatarProps) {
const initial = nickname.trim().slice(0, 1).toUpperCase()

if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={`${nickname} 프로필 이미지`}
className={cn(AVATAR_SIZE_CLASSNAME, 'rounded-full object-cover')}
/>
)
}

return (
<div
aria-label={`${nickname} 프로필 이미지`}
className={cn(
AVATAR_SIZE_CLASSNAME,
'flex items-center justify-center rounded-full bg-gray-200 text-5xl font-semibold text-gray-500'
)}
>
{initial || '?'}
</div>
)
}
61 changes: 61 additions & 0 deletions src/widgets/profile-header/ui/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { FollowButton } from '@/features/follow-user/ui/FollowButton'
import { cn } from '@/shared/lib/utils'
import { PROFILE_CONTAINER_CLASSNAME } from './constants'
import { ProfileAvatar } from './ProfileAvatar'
import { ProfileStat } from './ProfileStat'

interface ProfileHeaderProps {
className?: string
avatarUrl?: string | null
nickname: string
bio?: string | null
postsCount: number
followersCount: number
followingCount: number
defaultIsFollowing?: boolean
onFollowToggle?: (nextIsFollowing: boolean) => Promise<void> | void
}

export function ProfileHeader({
className,
avatarUrl,
nickname,
bio,
postsCount,
followersCount,
followingCount,
defaultIsFollowing = false,
onFollowToggle,
}: ProfileHeaderProps) {
return (
<section className={cn(PROFILE_CONTAINER_CLASSNAME, className)}>
<div className="flex gap-8 md:gap-16">
<div className="flex w-[180px] justify-center">
<ProfileAvatar avatarUrl={avatarUrl} nickname={nickname} />
</div>

<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-4">
<h1 className="text-xl font-normal text-gray-900">{nickname}</h1>
<FollowButton
defaultIsFollowing={defaultIsFollowing}
onToggle={onFollowToggle}
/>
</div>

<div className="mt-6 flex gap-10 text-base">
<ProfileStat label="게시물" value={postsCount} />
<ProfileStat label="팔로워" value={followersCount} />
<ProfileStat label="팔로잉" value={followingCount} />
</div>

{bio ? (
<p className="mt-5 text-sm leading-relaxed whitespace-pre-line text-gray-900">
{bio}
</p>
) : null}
</div>
</div>
</section>
)
}
15 changes: 15 additions & 0 deletions src/widgets/profile-header/ui/ProfileStat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { numberFormat } from '@/shared/lib/numberFormat'

type ProfileStatProps = {
label: string
value: number
}

export function ProfileStat({ label, value }: ProfileStatProps) {
return (
<div className="flex items-baseline gap-1">
<span className="font-semibold text-gray-900">{numberFormat(value)}</span>
<span className="text-gray-600">{label}</span>
</div>
)
}
3 changes: 3 additions & 0 deletions src/widgets/profile-header/ui/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const PROFILE_CONTAINER_CLASSNAME =
'mx-auto w-full max-w-[935px] px-4 py-8'
export const AVATAR_SIZE_CLASSNAME = 'size-[150px]'