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
186 changes: 51 additions & 135 deletions src/app/(root)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,29 @@
'use client';

import { useState } from 'react';
import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
Sidebar,
SidebarButton,
SidebarTeamSelect,
SidebarAddButton,
MobileHeader,
MobileDrawer,
} from '@/components/sidebar';
import boardSmall from '@/assets/icons/board/boardSmall.svg';
import boardLarge from '@/assets/icons/board/boardLarge.svg';
import chessSmall from '@/assets/icons/chess/chessSmall.svg';
import chessBig from '@/assets/icons/chess/chessBig.svg';
import { Sidebar, MobileHeader } from '@/components/sidebar';
import TeamSidebarDropdown from './[teamid]/_domain/components/Team/TeamSidebarDropdown';
import humanBig from '@/assets/buttons/human/humanBig.svg';
import styles from './layout.module.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const { data: user, isPending } = useCurrentUser();

// isPending: 최초 로딩 중 (undefined)
// user === null: 로딩 완료 후 비로그인
// user !== null: 로그인
const isLoggedIn = !isPending && user !== null && user !== undefined;
const isLanding = pathname === '/';
const firstGroup = user?.memberships?.[0]?.group;

// [teamid] 페이지는 자체 모바일 헤더(TeamNavClient)를 사용하므로 root layout의 MobileHeader를 숨김
const knownPaths = ['/', '/addteam', '/boards', '/mypage', '/history', '/list'];
const isTeamIdPage = !knownPaths.some((p) => pathname === p || pathname.startsWith(p + '/'));
// 자체 사이드바가 없는 페이지에서만 root layout 사이드바 표시
const rootSidebarPaths = ['/', '/boards', '/mypage'];
const showRootSidebar = rootSidebarPaths.some(
(p) => pathname === p || pathname.startsWith(p + '/'),
);

const handleProfileClick = () => {
if (isLoggedIn) {
Expand All @@ -51,126 +40,53 @@ export default function RootLayout({ children }: { children: React.ReactNode })

return (
<div className={styles.layout}>
<Sidebar
defaultCollapsed={isLanding}
isLoggedIn={isLoggedIn}
onProfileClick={handleProfileClick}
onLogout={handleLogout}
onLogoClick={() => router.push('/addteam')}
profileImage={
user?.image ? (
<Image
src={user.image}
alt=""
width={40}
height={40}
style={{ borderRadius: 12, objectFit: 'cover' }}
/>
) : (
<Image src={humanBig} alt="" width={40} height={40} />
)
}
profileName={user?.nickname ?? '사용자'}
profileTeam={firstGroup?.name ?? ''}
teamSelect={(isCollapsed: boolean) =>
firstGroup ? (
!isCollapsed ? (
<SidebarTeamSelect
icon={
firstGroup.image ? (
<Image
src={firstGroup.image}
alt=""
width={20}
height={20}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : (
<Image src={chessSmall} alt="" width={20} height={20} />
)
}
label={firstGroup.name}
isSelected
onClick={() => router.push(`/${firstGroup.id}`)}
{showRootSidebar && (
<Sidebar
defaultCollapsed={isLanding}
isLoggedIn={isLoggedIn}
onProfileClick={handleProfileClick}
onLogout={handleLogout}
onLogoClick={() => router.push('/addteam')}
profileImage={
user?.image ? (
<Image
src={user.image}
alt=""
width={40}
height={40}
style={{ borderRadius: 12, objectFit: 'cover' }}
/>
) : (
<SidebarButton
icon={
firstGroup.image ? (
<Image
src={firstGroup.image}
alt=""
width={24}
height={24}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : (
<Image src={chessBig} alt="" width={24} height={24} />
)
}
label={firstGroup.name}
isActive
iconOnly
onClick={() => router.push(`/${firstGroup.id}`)}
/>
<Image src={humanBig} alt="" width={40} height={40} />
)
) : null
}
addButton={
isLoggedIn
? (isCollapsed: boolean) => (
<>
{!isCollapsed && (
<SidebarAddButton label="팀 추가하기" onClick={() => router.push('/addteam')} />
)}
<hr style={{ border: 'none', borderTop: '1px solid #e2e8f0', margin: '8px 0' }} />
<SidebarButton
icon={
<Image
src={isCollapsed ? boardLarge : boardSmall}
alt=""
width={isCollapsed ? 24 : 20}
height={isCollapsed ? 24 : 20}
/>
}
label="자유게시판"
isActive
iconOnly={isCollapsed}
href="/boards"
/>
</>
)
: undefined
}
/>
{!isTeamIdPage && (
<>
<MobileHeader
isLoggedIn={isLoggedIn}
profileImage={
user?.image ? (
<Image
src={user.image}
alt=""
width={32}
height={32}
style={{ borderRadius: 8, objectFit: 'cover' }}
/>
) : undefined
}
onMenuClick={() => setIsDrawerOpen(true)}
onProfileClick={handleProfileClick}
/>
<MobileDrawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)}>
<SidebarButton
icon={<Image src={boardSmall} alt="" width={20} height={20} />}
label="자유게시판"
isActive
href="/boards"
onClick={() => setIsDrawerOpen(false)}
/>
</MobileDrawer>
</>
}
profileName={user?.nickname ?? '사용자'}
profileTeam={user?.memberships?.[0]?.group?.name ?? ''}
teamSelect={
isLoggedIn
? (isCollapsed: boolean) => <TeamSidebarDropdown isCollapsed={isCollapsed} />
: undefined
}
/>
)}
{showRootSidebar && (
<MobileHeader
isLoggedIn={isLoggedIn}
profileImage={
user?.image ? (
<Image
src={user.image}
alt=""
width={32}
height={32}
style={{ borderRadius: 8, objectFit: 'cover' }}
/>
) : undefined
}
onProfileClick={handleProfileClick}
onLogout={handleLogout}
drawerContent={<TeamSidebarDropdown />}
/>
)}
<main className={styles.main}>{children}</main>
</div>
Expand Down
20 changes: 16 additions & 4 deletions src/app/(root)/mypage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default function ProfilePage() {
} = useUser();

const [showToast, setShowToast] = useState(false);
const [successToast, setSuccessToast] = useState<string | null>(null);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false);
const newPasswordRef = useRef<HTMLInputElement>(null);
Expand All @@ -47,6 +48,7 @@ export default function ProfilePage() {
const result = await updateProfile();
if (result.success) {
setShowToast(false);
setSuccessToast('이름이 변경되었습니다.');
}
};

Expand All @@ -63,6 +65,7 @@ export default function ProfilePage() {
setIsPasswordModalOpen(false);
if (newPasswordRef.current) newPasswordRef.current.value = '';
if (confirmPasswordRef.current) confirmPasswordRef.current.value = '';
setSuccessToast('비밀번호가 변경되었습니다.');
}
};

Expand Down Expand Up @@ -171,8 +174,8 @@ export default function ProfilePage() {
</form>
</div>

{hasChanges && (
<div className={styles.toastWrapper}>
<div className={styles.toastWrapper}>
{hasChanges && (
<Toast
isOpen={showToast}
message="저장하지 않은 변경사항이 있어요!"
Expand All @@ -181,8 +184,17 @@ export default function ProfilePage() {
onDismiss={() => setShowToast(false)}
className={styles.toast}
/>
</div>
)}
)}
{successToast && (
<Toast
isOpen
message={successToast}
actionLabel=""
onDismiss={() => setSuccessToast(null)}
className={styles.toast}
/>
)}
Comment on lines +188 to +196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

hasChanges에 의한 안내 토스트와 successToast가 동시에 활성화될 경우, 두 토스트가 겹쳐서 보일 수 있습니다. toastWrapper의 CSS가 스택 구조를 지원하지 않는다면, 하나의 토스트 상태 변수를 사용하여 메시지 내용만 교체하는 방식으로 개선하는 것을 추천합니다.

</div>
</div>

<ChangePassword
Expand Down
46 changes: 28 additions & 18 deletions src/components/sidebar/MobileHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use client';

import { type ReactNode, useState, useRef, useEffect } from 'react';
import { type ReactNode, useState, useRef, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import clsx from 'clsx';
import MobileDrawer from './MobileDrawer';

import styles from './styles/MobileHeader.module.css';
import logoSmall from '@/assets/logos/logoSmall.svg';
Expand All @@ -15,8 +17,10 @@ type MobileHeaderProps = {
isLoggedIn?: boolean;
/** 프로필 이미지 (ReactNode로 자유롭게 주입, 미전달 시 기본 아이콘) */
profileImage?: ReactNode;
/** 햄버거 메뉴 버튼 클릭 시 호출되는 콜백 */
/** @deprecated 내부 드로어로 대체됨. 호환성을 위해 유지 */
onMenuClick?: () => void;
/** 드로어 내부 콘텐츠 (전달 시 햄버거 메뉴 클릭으로 드로어 표시) */
drawerContent?: ReactNode;
/** 프로필 버튼 클릭 시 호출되는 콜백 */
onProfileClick?: () => void;
/** 로그아웃 클릭 시 호출되는 콜백 */
Expand All @@ -37,16 +41,27 @@ type MobileHeaderProps = {
export default function MobileHeader({
isLoggedIn,
profileImage,
onMenuClick,
drawerContent,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

onMenuClickMobileHeaderProps에는 정의되어 있고 주석으로도 호환성을 위해 유지한다고 되어 있으나, 구조 분해 할당에서 누락되었습니다. 이를 추가해야 내부에서 사용할 수 있습니다.

Suggested change
drawerContent,
drawerContent, onMenuClick,

onProfileClick,
onLogout,
onLogoClick,
logoWidth = 102,
logoHeight = 20,
}: MobileHeaderProps) {
const router = useRouter();
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const profileMenuRef = useRef<HTMLDivElement>(null);

const defaultLogout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
}, [router]);

const handleLogout = onLogout ?? defaultLogout;
const handleProfileClick = onProfileClick ?? (() => router.push('/mypage'));
const handleLogoClick = onLogoClick ?? (() => router.push('/addteam'));
Comment on lines +56 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

로그아웃 처리 및 페이지 이동 로직이 MobileHeader, Sidebar, layout 세 곳에서 중복되어 정의되어 있습니다. 특히 defaultLogout 로직은 완전히 동일하므로, 이를 별도의 유틸리티 함수나 커스텀 훅으로 분리하여 코드 중복을 제거하고 유지보수성을 높이는 것이 좋습니다.


useEffect(() => {
if (!showProfileMenu) return;
const handleClickOutside = (e: MouseEvent) => {
Expand All @@ -61,12 +76,7 @@ export default function MobileHeader({
if (!isLoggedIn) {
return (
<header className={styles.header}>
<div
className={styles.logo}
onClick={onLogoClick}
role={onLogoClick ? 'button' : undefined}
tabIndex={onLogoClick ? 0 : undefined}
>
<div className={styles.logo} onClick={handleLogoClick} role="button" tabIndex={0}>
<Image src={logoSmall} alt="COWORKERS" width={logoWidth} height={logoHeight} />
</div>
</header>
Expand All @@ -79,17 +89,12 @@ export default function MobileHeader({
<button
type="button"
className={styles.menuButton}
onClick={onMenuClick}
onClick={() => setIsDrawerOpen(true)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

호환성을 위해 onMenuClick이 전달된 경우 드로어를 여는 것과 동시에 해당 콜백을 호출해 주는 것이 좋습니다.

Suggested change
onClick={() => setIsDrawerOpen(true)}
onClick={() => { onMenuClick?.(); setIsDrawerOpen(true); }}

aria-label="메뉴 열기"
>
<Image src={hamburger} alt="" width={24} height={24} />
</button>
<div
className={styles.logo}
onClick={onLogoClick}
role={onLogoClick ? 'button' : undefined}
tabIndex={onLogoClick ? 0 : undefined}
>
<div className={styles.logo} onClick={handleLogoClick} role="button" tabIndex={0}>
<Image src={logoIcon} alt="COWORKERS" width={24} height={24} />
</div>
</div>
Expand All @@ -109,7 +114,7 @@ export default function MobileHeader({
className={styles.profileMenuItem}
onClick={() => {
setShowProfileMenu(false);
onProfileClick?.();
handleProfileClick();
}}
>
마이페이지
Expand All @@ -119,14 +124,19 @@ export default function MobileHeader({
className={`${styles.profileMenuItem} ${styles.profileMenuDanger}`}
onClick={() => {
setShowProfileMenu(false);
onLogout?.();
handleLogout();
}}
>
로그아웃
</button>
</div>
)}
</div>
{drawerContent && (
<MobileDrawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)}>
{drawerContent}
</MobileDrawer>
)}
</header>
);
}
Loading
Loading