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
12 changes: 10 additions & 2 deletions src/components/common/TitleEditorPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface TitleEditorPopoverProps {
ariaLabel: string;
isPending?: boolean;
titleClassName?: string;
showOnMobile?: boolean;
}

export function TitleEditorPopover({
Expand All @@ -33,6 +34,7 @@ export function TitleEditorPopover({
ariaLabel,
isPending = false,
titleClassName = 'max-w-60 truncate',
showOnMobile = false,
}: TitleEditorPopoverProps) {
const [editTitle, setEditTitle] = useState(title);

Expand All @@ -47,7 +49,10 @@ export function TitleEditorPopover({
<button
type="button"
aria-label={ariaLabel}
className="hidden md:inline-flex h-7 items-center gap-1.5 rounded-md bg-transparent px-2 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main min-w-0"
className={clsx(
'h-7 items-center gap-1.5 rounded-md bg-transparent px-2 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main min-w-0',
showOnMobile ? 'inline-flex' : 'hidden md:inline-flex',
)}
Comment on lines +52 to +55
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

className을 생성하는 로직이 77-80 라인에서도 동일하게 반복되고 있습니다. 코드 중복을 줄이고 유지보수성을 높이기 위해 이 clsx 호출을 컴포넌트 상단의 변수로 추출하여 두 버튼에서 공유하는 것을 권장합니다.

예시:

const buttonClassName = clsx(
  'h-7 items-center gap-1.5 rounded-md bg-transparent px-2 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main min-w-0',
  showOnMobile ? 'inline-flex' : 'hidden md:inline-flex',
);
References
  1. Extract complex or repetitive conditional className logic into a helper function, using utilities like clsx to improve readability and maintainability.

>
<span className={titleClassName}>{title}</span>
<InfoIcon className="h-4 w-4" aria-hidden="true" />
Expand All @@ -69,7 +74,10 @@ export function TitleEditorPopover({
<button
type="button"
aria-label={ariaLabel}
className="hidden md:inline-flex h-7 items-center gap-1.5 rounded-md bg-transparent px-2 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main min-w-0"
className={clsx(
'h-7 items-center gap-1.5 rounded-md bg-transparent px-2 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main min-w-0',
showOnMobile ? 'inline-flex' : 'hidden md:inline-flex',
)}
>
<span className={titleClassName}>{title}</span>
<ArrowDownIcon
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/layout/HeaderButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function HeaderButton({
className,
)}
>
<span className={clsx(shouldHideTextOnMobile && 'hidden md:inline')}>{text}</span>
<span className={clsx(shouldHideTextOnMobile && 'hidden lg:inline')}>{text}</span>
{icon}
</button>
);
Expand Down
131 changes: 27 additions & 104 deletions src/components/common/layout/LoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,17 @@
* 로그인 상태: 사용자 이름 + 프로필 이미지 (클릭 시 로그아웃/회원탈퇴 드롭다운)
*/
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';

import { useQueryClient } from '@tanstack/react-query';

import { apiClient } from '@/api/client';
import LoginIcon from '@/assets/icons/icon-login.svg?react';
import LogoutIcon from '@/assets/icons/icon-logout.svg?react';
import { Popover } from '@/components/common/Popover';
import { Dropdown } from '@/components/common/Dropdown';
import { UserAvatar } from '@/components/common/UserAvatar';
import { useAuthStore } from '@/stores/authStore';
import { useHomeStore } from '@/stores/homeStore';
import { useThemeStore } from '@/stores/themeStore';
import { isAnonymousEmail } from '@/utils/auth';
import { showToast } from '@/utils/toast';
import { getUserDisplayName } from '@/utils/user';
Expand All @@ -28,23 +27,18 @@ import { WithdrawConfirmModal } from './WithdrawConfirmModal';
export function LoginButton() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pathname } = useLocation();
const accessToken = useAuthStore((s) => s.accessToken);
const user = useAuthStore((s) => s.user);
const openLoginModal = useAuthStore((s) => s.openLoginModal);
const logout = useAuthStore((s) => s.logout);
const resetHome = useHomeStore((s) => s.reset);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const setTheme = useThemeStore((s) => s.setTheme);

const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false);
const [isWithdrawing, setIsWithdrawing] = useState(false);

const isGuest = !accessToken;
const isAnon = accessToken && isAnonymousEmail(user?.email);
const isSocial = accessToken && user?.email && !isAnonymousEmail(user.email);
const isSlideRoute = /\/slide\/?$/.test(pathname);
const isDark = resolvedTheme === 'dark';

const handleLogout = () => {
logout();
Expand All @@ -57,12 +51,7 @@ export function LoginButton() {
// 로그인 전 (게스트)
if (isGuest) {
return (
<HeaderButton
text="로그인"
icon={<LoginIcon />}
onClick={openLoginModal}
iconOnlyOnMobile={isSlideRoute}
/>
<HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} iconOnlyOnMobile />
);
}

Expand All @@ -74,12 +63,7 @@ export function LoginButton() {
// 소셜이 아닌데 여기까지 왔다면(비정상 상태) 방어
if (!isSocial) {
return (
<HeaderButton
text="로그인"
icon={<LoginIcon />}
onClick={openLoginModal}
iconOnlyOnMobile={isSlideRoute}
/>
<HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} iconOnlyOnMobile />
);
}

Expand All @@ -101,101 +85,40 @@ export function LoginButton() {

return (
<>
<Popover
<Dropdown
key={`${accessToken ?? 'guest'}-${user?.id ?? 'nouser'}`}
position="bottom"
align="end"
ariaLabel="사용자 메뉴"
className="mt-2 w-80 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-[0_0.5rem_1.25rem_rgba(0,0,0,0.08)]"
trigger={
<button
type="button"
className="flex cursor-pointer items-center gap-2 rounded-full px-2 py-1 text-body-s-bold text-gray-800 transition-colors hover:bg-gray-100"
className="flex cursor-pointer items-center gap-2 text-body-s-bold text-gray-800 transition-colors hover:text-gray-600"
>
<span
className={isSlideRoute ? 'hidden md:inline max-w-24 truncate' : 'max-w-24 truncate'}
>
{displayName}
</span>
<span className="hidden min-[1024px]:inline max-w-24 truncate">{displayName}</span>
<UserAvatar src={user.profileImage} alt={displayName} size={24} />
</button>
}
>
{({ close }) => (
<div className="p-3">
<div className="rounded-lg px-3 py-3">
<p className="text-caption-bold text-gray-600">내 계정</p>
<div className="mt-2 flex items-center gap-3">
<UserAvatar src={user.profileImage} alt={displayName} size={42} />
<div className="min-w-0">
<p className="truncate text-body-m-bold text-gray-800">{displayName}</p>
<p className="truncate text-caption text-gray-600">{user.email}</p>
</div>
</div>
</div>

<div className="mt-2 border-t border-gray-200 pt-2">
<button
type="button"
className="flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-gray-100"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
>
<div>
<p className="text-body-s-bold text-gray-800">테마</p>
<p className="text-caption text-gray-600">
{isDark ? '다크 모드 사용 중' : '라이트 모드 사용 중'}
</p>
</div>
<span
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
isDark ? 'bg-main' : 'bg-gray-400'
}`}
aria-hidden="true"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isDark ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</span>
</button>
</div>

<div className="mt-1 border-t border-gray-200 pt-2">
<button
type="button"
className="flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left text-body-s text-gray-800 transition-colors hover:bg-gray-100"
onClick={() => {
close();
handleLogout();
}}
>
<div>
<p className="text-body-s-bold">로그아웃</p>
<p className="text-caption text-gray-600">현재 계정에서 로그아웃합니다.</p>
</div>
<LogoutIcon className="size-5 text-gray-400" />
</button>
</div>

<div className="mt-1 border-t border-gray-200 pt-2">
<button
type="button"
className="w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-gray-100"
onClick={() => {
close();
setIsWithdrawModalOpen(true);
}}
>
<p className="text-caption-bold text-error">회원 탈퇴</p>
<p className="text-caption text-gray-600">
계정과 데이터가 삭제되며 되돌릴 수 없습니다.
</p>
</button>
</div>
</div>
)}
</Popover>
items={[
{
id: 'logout',
label: (
<span className="flex items-center gap-1">
로그아웃
<LogoutIcon className="size-6" />
</span>
),
onClick: handleLogout,
variant: 'danger',
},
{
id: 'withdraw',
label: '회원 탈퇴',
onClick: () => setIsWithdrawModalOpen(true),
variant: 'danger',
},
]}
/>

<WithdrawConfirmModal
isOpen={isWithdrawModalOpen}
Expand Down
11 changes: 1 addition & 10 deletions src/components/common/layout/ShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,15 @@
* @file ShareButton.tsx
* @description 공유 모달을 여는 헤더 버튼
*/
import { useLocation } from 'react-router-dom';

import ShareIcon from '@/assets/icons/icon-share.svg?react';
import { useShareStore } from '@/stores/shareStore';

import { HeaderButton } from './HeaderButton';

export function ShareButton() {
const openShareModal = useShareStore((s) => s.openShareModal);
const { pathname } = useLocation();
const isSlideRoute = /\/slide\/?$/.test(pathname);

return (
<HeaderButton
text="공유"
icon={<ShareIcon />}
onClick={openShareModal}
iconOnlyOnMobile={isSlideRoute}
/>
<HeaderButton text="공유" icon={<ShareIcon />} onClick={openShareModal} iconOnlyOnMobile />
);
}
1 change: 1 addition & 0 deletions src/components/slide/script/SlideTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function SlideTitleEditable({
isCollapsed={isCollapsed}
ariaLabel="슬라이드 이름 변경"
titleClassName="max-w-40 truncate"
showOnMobile
/>
);
}
63 changes: 62 additions & 1 deletion src/pages/SlidePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export default function SlidePage() {

const slideIdParam = searchParams.get('slideId');
const currentSlide = slides?.find((s) => s.slideId === slideIdParam) ?? slides?.[0];
const currentIndex = currentSlide
? (slides?.findIndex((s) => s.slideId === currentSlide.slideId) ?? -1)
: -1;
const hasPrev = currentIndex > 0;
const hasNext = !!slides && currentIndex >= 0 && currentIndex < slides.length - 1;

/**
* 슬라이드 로드 에러 처리
Expand Down Expand Up @@ -49,15 +54,71 @@ export default function SlidePage() {
}
}, [projectId, currentSlide?.slideId]);

const goPrev = () => {
if (!slides || !hasPrev) return;
setSearchParams({ slideId: slides[currentIndex - 1].slideId }, { replace: true });
};

const goNext = () => {
if (!slides || !hasNext) return;
setSearchParams({ slideId: slides[currentIndex + 1].slideId }, { replace: true });
};

return (
<div className="h-full bg-gray-100">
<div className="flex h-full gap-12 pl-14 pr-20 pt-6">
<div className="hidden min-[1024px]:flex h-full gap-12 pl-14 pr-20 pt-6">
<SlideList slides={slides} currentSlideId={currentSlide?.slideId} isLoading={isLoading} />

<main className="flex-1 h-full min-w-0 overflow-hidden">
<SlideWorkspace slide={currentSlide} isLoading={isLoading} />
</main>
</div>

<div className="flex min-[1024px]:hidden h-full flex-col px-4 py-4">
<div className="flex items-center justify-between pb-3">
<button
type="button"
onClick={goPrev}
disabled={!hasPrev}
className="rounded-full border border-gray-200 bg-white p-2 text-gray-800 shadow-sm disabled:cursor-not-allowed disabled:opacity-35"
aria-label="이전 슬라이드"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="m15 18-6-6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Comment on lines +86 to +94
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

이전 슬라이드로 가는 버튼의 SVG 아이콘이 코드에 직접 포함되어 있습니다. 106-114 라인의 다음 슬라이드 버튼 아이콘도 마찬가지입니다. 프로젝트의 다른 부분에서 아이콘을 처리하는 방식과 일관성을 맞추고 재사용성을 높이기 위해 이 SVG들을 별도의 파일(예: ChevronLeftIcon.svg, ChevronRightIcon.svg)로 추출하고, svgr을 통해 React 컴포넌트로 임포트하여 사용하는 것을 권장합니다.

</button>
<span className="text-body-s-bold text-gray-800">
{slides && currentIndex >= 0 ? `${currentIndex + 1} / ${slides.length}` : '- / -'}
</span>
<button
type="button"
onClick={goNext}
disabled={!hasNext}
className="rounded-full border border-gray-200 bg-white p-2 text-gray-800 shadow-sm disabled:cursor-not-allowed disabled:opacity-35"
aria-label="다음 슬라이드"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="m9 6 6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>

<div className="min-h-0 flex-1 overflow-hidden">
<SlideWorkspace slide={currentSlide} isLoading={isLoading} />
</div>
</div>
</div>
);
}
Loading