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
1 change: 1 addition & 0 deletions src/shared/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
*/
export const ROUTES = {
LANDING: '/',
LOGIN: '/login',
WRITING: '/writing',
} as const;
2 changes: 2 additions & 0 deletions src/shared/styles/base/colors.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
--color-white: #ffffff;

/* Shadow Tokens */
--shadow-header-login-button: 0 0 24px rgba(118, 186, 255, 0.28);
--shadow-header-login-button-hover: 0 8px 28px rgba(118, 186, 255, 0.36);
--shadow-tooltip-arrow: 0 0 16px rgba(118, 186, 255, 0.18);
--shadow-tooltip: 0 14px 44px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(118, 186, 255, 0.08);
}
1 change: 1 addition & 0 deletions src/shared/styles/base/z-index.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
예시: z-(--z-index-surface-content)
*/
--z-index-form-floating: 10;
--z-index-header: 30;
--z-index-surface-overlay: 40;
--z-index-surface-content: 50;
--z-index-tooltip: 60;
Expand Down
63 changes: 63 additions & 0 deletions src/widgets/common/header/Header.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { DefaultProfileImage } from '@/shared/assets/images';
import { ROUTES } from '@/shared/constants/routes';

import { Header } from './Header';

import type { Meta, StoryObj } from '@storybook/nextjs';

const meta = {
title: 'Widgets/Common/Header',
component: Header,
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<div className="min-h-120 bg-black text-white">
<Story />
<main className="mx-auto flex max-w-7xl flex-col gap-4 px-5 py-12 md:px-8">
<h1 className="heading-32 font-semibold">Header Preview</h1>
<p className="body-16 max-w-180 text-gray-100">
스크롤 시 Header 배경과 blur가 적용됩니다. Storybook에서는 Scrolled 스토리로 상태를
고정해서 확인할 수 있습니다.
</p>
<div className="h-120 rounded-lg border border-white/10 bg-gray-900" />
</main>
</div>
),
],
args: {
authenticatedLogoHref: ROUTES.WRITING,
loginHref: ROUTES.LOGIN,
},
} satisfies Meta<typeof Header>;

export default meta;

type Story = StoryObj<typeof meta>;

const loggedInUser = {
name: '이서정',
profileImageUrl: DefaultProfileImage.src,
};

export const Guest: Story = {};

export const GuestScrolled: Story = {
args: {
isBackgroundBlurred: true,
},
};

export const LoggedIn: Story = {
args: {
user: loggedInUser,
},
};

export const LoggedInScrolled: Story = {
args: {
isBackgroundBlurred: true,
user: loggedInUser,
},
};
96 changes: 96 additions & 0 deletions src/widgets/common/header/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client';

import { useEffect, useState } from 'react';
import type { ComponentPropsWithoutRef } from 'react';

import { Profile, type UserProfile } from '@/entities/user';
import { ROUTES } from '@/shared/constants/routes';
import { cn } from '@/shared/styles/utils/cn';
import { LinkButton } from '@/shared/ui/button';
import { TextLogo } from '@/shared/ui/logo';

interface HeaderProps extends ComponentPropsWithoutRef<'header'> {
user?: UserProfile | null;
logoHref?: string;
loginHref?: string;
authenticatedLogoHref?: string;
isBackgroundBlurred?: boolean;
blurThreshold?: number;
}

/**
* ## Header
*
* @description
* 서비스 상단에서 사용하는 공통 Header 위젯입니다.
* 게스트는 로그인 CTA를, 로그인 사용자는 프로필을 표시합니다.
*
* @param user - 로그인 사용자 정보. 없으면 게스트 Header로 렌더링합니다.
* @param logoHref - 로고 클릭 시 이동할 경로. 기본값은 로그인 여부에 따라 결정합니다.
* @param authenticatedLogoHref - 로그인 상태일 때 로고 클릭 시 이동할 기본 경로입니다.
* @param isBackgroundBlurred - 전달하면 스크롤 감지 대신 blur 상태를 외부에서 제어합니다.
* @param blurThreshold - 스크롤 blur가 활성화되는 기준 scrollY 값입니다.
*/
export function Header({
authenticatedLogoHref = ROUTES.WRITING,
blurThreshold = 0,
className,
isBackgroundBlurred,
loginHref = ROUTES.LOGIN,
logoHref,
user,
...props
}: HeaderProps) {
const [hasScrolled, setHasScrolled] = useState(false);
const isLoggedIn = Boolean(user);
const resolvedLogoHref = logoHref ?? (isLoggedIn ? authenticatedLogoHref : ROUTES.LANDING);
const shouldBlur = isBackgroundBlurred ?? hasScrolled;

useEffect(() => {
if (isBackgroundBlurred !== undefined) {
return;
}

const handleScroll = () => {
setHasScrolled(window.scrollY > blurThreshold);
};

handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });

return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [blurThreshold, isBackgroundBlurred]);

return (
<header
className={cn(
'sticky top-0 z-(--z-index-header) w-full transition-[background-color,box-shadow,backdrop-filter] duration-200',
shouldBlur && 'bg-black/70 shadow-lg shadow-black/20 backdrop-blur-md',
className
)}
data-scrolled={shouldBlur}
{...props}
>
<div className="mx-auto flex h-16 w-full items-center justify-between px-5 xl:px-20">
<TextLogo aria-label="Re:write 홈으로 이동" href={resolvedLogoHref} />

{user ? (
<Profile name={user.name} profileImageUrl={user.profileImageUrl} />
) : (
<nav aria-label="게스트 메뉴" className="flex items-center">
<LinkButton
className="h-9 w-auto rounded-full border border-primary-300/40 bg-primary-500 px-5 text-white shadow-header-login-button transition-all duration-200 hover:-translate-y-0.5 hover:border-primary-100 hover:bg-primary-400 hover:shadow-header-login-button-hover active:translate-y-0 active:bg-primary-600"
href={loginHref}
size="sm"
variant="primary"
>
로그인
</LinkButton>
</nav>
)}
</div>
</header>
);
}
1 change: 1 addition & 0 deletions src/widgets/common/header/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Header } from './Header';
Loading