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
14 changes: 12 additions & 2 deletions src/components/common/layout/HeaderButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,32 @@ interface HeaderButtonProps {
icon?: ReactNode;
onClick: () => void;
className?: string;
iconOnlyOnMobile?: boolean;
}

/**
* @description 헤더 우측 슬롯에서 공통으로 사용되는 아이콘+텍스트 버튼 컴포넌트
*/
export function HeaderButton({ text, icon, onClick, className }: HeaderButtonProps) {
export function HeaderButton({
text,
icon,
onClick,
className,
iconOnlyOnMobile = false,
}: HeaderButtonProps) {
const shouldHideTextOnMobile = iconOnlyOnMobile && !!icon;

return (
<button
type="button"
onClick={onClick}
className={clsx(
'flex items-center gap-1 text-body-s-bold text-gray-800 cursor-pointer transition-colors hover:text-gray-600',
shouldHideTextOnMobile && 'justify-center',
className,
)}
>
{text}
<span className={clsx(shouldHideTextOnMobile && 'hidden md:inline')}>{text}</span>
{icon}
</button>
);
Expand Down
135 changes: 108 additions & 27 deletions src/components/common/layout/LoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@
* 로그인 상태: 사용자 이름 + 프로필 이미지 (클릭 시 로그아웃/회원탈퇴 드롭다운)
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, 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 { Dropdown } from '@/components/common/Dropdown';
import { Popover } from '@/components/common/Popover';
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 @@ -27,18 +28,23 @@ 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 @@ -50,7 +56,14 @@ export function LoginButton() {
};
// 로그인 전 (게스트)
if (isGuest) {
return <HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} />;
return (
<HeaderButton
text="로그인"
icon={<LoginIcon />}
onClick={openLoginModal}
iconOnlyOnMobile={isSlideRoute}
/>
);
}

// 익명 사용자
Expand All @@ -60,7 +73,14 @@ export function LoginButton() {

// 소셜이 아닌데 여기까지 왔다면(비정상 상태) 방어
if (!isSocial) {
return <HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} />;
return (
<HeaderButton
text="로그인"
icon={<LoginIcon />}
onClick={openLoginModal}
iconOnlyOnMobile={isSlideRoute}
/>
);
}

const handleWithdraw = async () => {
Expand All @@ -81,40 +101,101 @@ export function LoginButton() {

return (
<>
<Dropdown
<Popover
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 text-body-s-bold text-gray-800 transition-colors hover:text-gray-600"
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"
>
{displayName}
<span
className={isSlideRoute ? 'hidden md:inline max-w-24 truncate' : 'max-w-24 truncate'}
>
{displayName}
</span>
<UserAvatar src={user.profileImage} alt={displayName} size={24} />
</button>
}
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',
},
]}
/>
>
{({ 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>

<WithdrawConfirmModal
isOpen={isWithdrawModalOpen}
Expand Down
27 changes: 23 additions & 4 deletions src/components/common/layout/PresentationTitleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - 클릭하면 Popover 열리고, 입력/저장 가능
* - Enter 또는 저장 버튼으로 제출
*/
import { useParams } from 'react-router-dom';
import { useLocation, useParams } from 'react-router-dom';

import { usePresentation, useUpdatePresentation } from '@/hooks/queries/usePresentations';
import { showToast } from '@/utils/toast';
Expand All @@ -15,33 +15,51 @@ import { TitleEditorPopover } from '../TitleEditorPopover';

interface PresentationTitleEditorProps {
readOnlyContent?: React.ReactNode;
titleOverride?: string;
}

export function PresentationTitleEditor({ readOnlyContent }: PresentationTitleEditorProps) {
export function PresentationTitleEditor({
readOnlyContent,
titleOverride,
}: PresentationTitleEditorProps) {
const { projectId } = useParams<{ projectId: string }>();
const { pathname } = useLocation();
const { data: presentation } = usePresentation(projectId ?? '');

const resolvedTitle = presentation?.title?.trim() ? presentation.title : '내 발표';
const resolvedTitle =
titleOverride?.trim() || (presentation?.title?.trim() ? presentation.title : '내 발표');
const isProjectTabPath =
/^\/[^/]+\/(slide|insight|videos)(\/[^/]+)?$/.test(pathname) || pathname.endsWith('/videos');
Comment on lines +31 to +32
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

이 조건문의 || pathname.endsWith('/videos') 부분은 불필요해 보입니다. 정규식은 이미 /:projectId/videos와 같은 경로를 올바르게 처리하고 있습니다. Router.tsx를 보면 최상위 /videos 경로는 정의되어 있지 않으므로, 이 추가적인 검사가 필요한 경우는 없을 것으로 보입니다. 불필요한 코드를 제거하면 코드가 더 명확해지고 유지보수하기 좋아질 것입니다.

Suggested change
const isProjectTabPath =
/^\/[^/]+\/(slide|insight|videos)(\/[^/]+)?$/.test(pathname) || pathname.endsWith('/videos');
const isProjectTabPath = /^\/[^/]+\/(slide|insight|videos)(\/[^/]+)?$/.test(pathname);

const titleClassName = isProjectTabPath ? 'max-w-52 truncate' : undefined;

if (readOnlyContent) {
return (
<TitleEditorPopover
title={resolvedTitle}
readOnlyContent={readOnlyContent}
ariaLabel="발표 정보"
titleClassName={titleClassName}
/>
);
}

return <PresentationTitleEditorEditable projectId={projectId} title={resolvedTitle} />;
return (
<PresentationTitleEditorEditable
projectId={projectId}
title={resolvedTitle}
titleClassName={titleClassName}
/>
);
}

function PresentationTitleEditorEditable({
projectId,
title,
titleClassName,
}: {
projectId?: string;
title: string;
titleClassName?: string;
}) {
const { mutate: updatePresentation, isPending } = useUpdatePresentation();

Expand Down Expand Up @@ -74,6 +92,7 @@ function PresentationTitleEditorEditable({
onSave={handleSave}
ariaLabel="발표 이름 변경"
isPending={isPending}
titleClassName={titleClassName}
/>
);
}
13 changes: 12 additions & 1 deletion src/components/common/layout/ShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,24 @@
* @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} />;
return (
<HeaderButton
text="공유"
icon={<ShareIcon />}
onClick={openShareModal}
iconOnlyOnMobile={isSlideRoute}
/>
);
}
14 changes: 2 additions & 12 deletions src/components/feedback/FeedbackHeaderCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import { useParams } from 'react-router-dom';

import InfoIcon from '@/assets/icons/icon-info.svg?react';
import { Popover } from '@/components/common';
import { usePresentation } from '@/hooks/queries/usePresentations';
import dayjs from '@/utils/dayjs';
import { useFeedbackHeaderInfo } from '@/hooks/useFeedbackHeaderInfo';

export default function FeedbackHeaderCenter() {
const { projectId } = useParams<{ projectId: string }>();

const { data: presentation } = usePresentation(projectId ?? '');
const title = presentation?.title?.trim() ? presentation.title : '내 발표';
const postedAt = presentation?.updatedAt
? dayjs(presentation.updatedAt).format('YYYY.MM.DD HH:mm:ss')
: '-';
const publisherName = presentation?.userName ?? '알 수 없음';
const { title, postedAt, publisherName } = useFeedbackHeaderInfo();

return (
<div className="flex md:hidden items-center">
Expand Down
14 changes: 3 additions & 11 deletions src/components/feedback/FeedbackHeaderLeft.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import { useParams } from 'react-router-dom';

import { Logo, PresentationTitleEditor } from '@/components/common';
import { usePresentation } from '@/hooks/queries/usePresentations';
import dayjs from '@/utils/dayjs';
import { useFeedbackHeaderInfo } from '@/hooks/useFeedbackHeaderInfo';

export default function FeedbackHeaderLeft() {
const { projectId } = useParams<{ projectId: string }>();

const { data: presentation } = usePresentation(projectId ?? '');
const postedAt = presentation?.updatedAt
? dayjs(presentation.updatedAt).format('YYYY.MM.DD HH:mm:ss')
: '-';
const publisherName = presentation?.userName ?? '알 수 없음';
const { title, postedAt, publisherName } = useFeedbackHeaderInfo();

return (
<>
<Logo />
<PresentationTitleEditor
titleOverride={title}
readOnlyContent={
<div className="grid grid-cols-[6.5rem_1fr] gap-x-5 gap-y-3 text-body-m text-gray-800">
<span className="text-gray-600 text-body-s-bold">게시자</span>
Expand Down
6 changes: 3 additions & 3 deletions src/components/feedback/ScriptPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ export default function ScriptPanel({
return (
<div id={id} role="tabpanel" aria-labelledby={ariaLabelledby} className={className}>
<SlideTitle fallbackTitle={fallbackTitle} />
<div className="mt-3 bg-gray-200 rounded-lg px-4 py-3 h-48 overflow-y-auto">
<div className="mt-3 rounded-2xl border border-gray-200 bg-gray-200 px-4 py-3 h-48 overflow-y-auto pb-4">
<p
className={`text-body-s ${script ? 'text-black' : 'text-gray-600'}`}
style={{ whiteSpace: 'pre-line' }}
className={`text-body-s leading-relaxed wrap-break-word ${script ? 'text-black' : 'text-gray-600'}`}
style={{ whiteSpace: 'pre-wrap' }}
>
{script || '대본이 없습니다.'}
</p>
Expand Down
Loading
Loading