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
37 changes: 37 additions & 0 deletions src/components/common/BottomModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useRef, type HTMLAttributes, type ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import useClickTouchOutside from '@/utils/hooks/useClickTouchOutside';
import useScrollLock from '@/utils/hooks/useScrollLock';
import Portal from './Portal';

interface BottomModalProps extends HTMLAttributes<HTMLDivElement> {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}

function BottomModal({ isOpen, onClose, children, className }: BottomModalProps) {
const modalRef = useRef<HTMLDivElement>(null);

useClickTouchOutside(modalRef, onClose);
useScrollLock(isOpen);

if (!isOpen) return null;

return (
<Portal>
<div className="fixed inset-0 z-100 bg-black/60">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
className={twMerge('fixed inset-x-0 bottom-0 rounded-t-3xl bg-white', className)}
>
{children}
</div>
</div>
</Portal>
);
}

export default BottomModal;
79 changes: 79 additions & 0 deletions src/components/common/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { ReactNode } from 'react';
import clsx from 'clsx';
import { useBottomSheet, type SheetPosition } from '@/utils/hooks/useBottomSheet';

interface BottomSheetProps {
/** children으로 ReactNode 또는 render prop 함수 전달 가능 */
children: ReactNode | ((position: SheetPosition) => ReactNode);
/** 드래그로 크기 조절 가능 여부 (기본값: false) */
resizable?: boolean;
/** 초기 위치 (기본값: 'half') */
defaultPosition?: SheetPosition;
/** 하단 오프셋 (px) */
bottomOffset?: number;
/** half 상태일 때 상단 오프셋 (px) */
halfTopOffset?: number;
/** full 상태일 때 상단 오프셋 (px) */
fullTopOffset?: number;
/** 추가 className */
className?: string;
/** 현재 position을 외부로 전달하는 콜백 */
onPositionChange?: (position: SheetPosition) => void;
}

export default function BottomSheet({
children,
resizable = false,
defaultPosition = 'half',
bottomOffset = 0,
halfTopOffset = 105,
fullTopOffset = 48,
className,
onPositionChange,
}: BottomSheetProps) {
const { position, isDragging, currentTranslate, sheetRef, handlers } = useBottomSheet({
defaultPosition,
onPositionChange,
});
Comment on lines +34 to +37
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

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

When resizable is false, the hook still runs and manages state unnecessarily. The handlers are created but never used, which wastes resources. Consider adding a condition to skip hook initialization when resizable is false, or create a simpler version of the component for non-resizable cases.

Copilot uses AI. Check for mistakes.

const effectivePosition = resizable ? position : defaultPosition;

const getTransform = () => {
if (resizable) {
return effectivePosition === 'half'
? `translateY(calc(55% + ${currentTranslate}px))`
: `translateY(${Math.max(0, currentTranslate)}px)`;
}
return effectivePosition === 'half' ? 'translateY(55%)' : 'translateY(0)';
};

const content = typeof children === 'function' ? children(effectivePosition) : children;

return (
<div
role="region"
aria-label="Bottom Sheet"
ref={sheetRef}
className={clsx(
'fixed inset-x-0 z-20 flex flex-col rounded-t-3xl bg-white transition-transform duration-300 ease-out',
resizable && isDragging && 'transition-none',
className
)}
style={{
bottom: `${bottomOffset}px`,
height:
effectivePosition === 'full'
? `calc(100% - ${fullTopOffset}px - ${bottomOffset}px)`
: `calc(100% - ${halfTopOffset}px - ${bottomOffset}px)`,
transform: getTransform(),
}}
>
{resizable && (
<div className="flex h-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing" {...handlers}>
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

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

The draggable handle element is missing keyboard accessibility. Users who cannot use touch gestures should be able to control the sheet expansion/collapse using keyboard controls. Consider making the handle focusable and adding keyboard event handlers for arrow keys or Enter/Space keys.

Suggested change
<div className="flex h-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing" {...handlers}>
<div
className="flex h-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing"
{...handlers}
role="button"
tabIndex={0}
aria-label="Resize bottom sheet"
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
const anyHandlers = handlers as any;
if (typeof anyHandlers.onMouseDown === 'function') {
anyHandlers.onMouseDown(event);
} else if (typeof anyHandlers.onPointerDown === 'function') {
anyHandlers.onPointerDown(event);
}
}
}}
>

Copilot uses AI. Check for mistakes.
<div className="h-1 w-11 rounded-full bg-indigo-300" />
</div>
)}
{content}
</div>
);
}
4 changes: 4 additions & 0 deletions src/components/layout/Header/routeTitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ export const ROUTE_TITLES: RouteTitle[] = [
match: (pathname) => pathname.startsWith('/clubs/search'),
title: '동아리 검색',
},
{
match: (pathname) => pathname === '/chats',
title: '채팅방',
},
];
3 changes: 2 additions & 1 deletion src/pages/Auth/SignUp/components/StepLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ function StepLayout({
<button
onClick={onNext}
disabled={nextDisabled}
className="bg-primary text-indigo-0 mb-8 h-12 w-full items-center rounded-lg font-extrabold disabled:cursor-not-allowed disabled:opacity-50"
className="bg-primary text-indigo-0 h-12 w-full items-center rounded-lg font-extrabold disabled:cursor-not-allowed disabled:opacity-50"
style={{ marginBottom: 'calc(32px + var(--sab))' }}
>
다음
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const useChat = (chatRoomId?: number) => {
initialPageParam: 1,
getNextPageParam: (lastPage) => (lastPage.currentPage < lastPage.totalPage ? lastPage.currentPage + 1 : undefined),
enabled: !!chatRoomId,
refetchInterval: 3000,
refetchInterval: 1000,
});

const allMessages = chatMessagesData?.pages.flatMap((page) => page.messages) ?? [];
Expand Down
4 changes: 2 additions & 2 deletions src/pages/Chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ function ChatListPage() {
if (chatRoomList.chatRooms.length === 0) {
return (
<div className="bg-indigo-0 flex min-h-0 flex-1 flex-col items-center justify-center py-3">
<div className="text-sm text-gray-500">채팅방이 없습니다</div>
<div className="mt-1 text-xs text-gray-400">동아리에 문의하면 채팅이 시작됩니다</div>
<div className="text-sm text-gray-500">채팅방이 없어요</div>
<div className="mt-1 text-xs text-gray-400">동아리에 문의하면 채팅이 시작돼요</div>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Council/CouncilNotice/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function CouncilNotice() {
<div className="leading-5 font-semibold text-indigo-700">{councilNoticeDetail.title}</div>
<div className="mt-2 text-xs leading-3.5 text-indigo-300">{councilNoticeDetail.updatedAt}</div>
</div>
<div className="bg-indigo-0 flex-1 px-5 py-4 text-[13px] leading-4.5 whitespace-pre-line text-indigo-700">
<div className="bg-indigo-0 flex-1 px-5 pt-4 pb-20 text-[13px] leading-4.5 whitespace-pre-line text-indigo-700">
{councilNoticeDetail.content.replace(/\\n/g, '\n')}
</div>
</div>
Expand Down
90 changes: 38 additions & 52 deletions src/pages/Timer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useState, useTransition } from 'react';
import clsx from 'clsx';
import type { StudyRankingParams } from '@/apis/studyTime/entity';
import BottomSheet from '@/components/common/BottomSheet';
import Dropdown from '@/components/common/Dropdown';
import { useBottomSheet } from '@/utils/hooks/useBottomSheet';
import { RankingList } from './components/RankingItem';
import TimerButton from './components/TimerButton';
import { useStudyTimer } from './hooks/useStudyTimer';
Expand All @@ -22,7 +22,6 @@ const SORT_OPTIONS = [

function TimerPage() {
const { todayAccumulatedSeconds, sessionStartMs, isRunning, toggle, isStarting, isStopping } = useStudyTimer();
const { position, isDragging, currentTranslate, sheetRef, handlers } = useBottomSheet();

const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<TabType>('개인');
Expand All @@ -46,58 +45,45 @@ function TimerPage() {
</div>
</div>

<div
ref={sheetRef}
className={clsx(
'fixed inset-x-0 z-20 flex flex-col rounded-t-3xl bg-white transition-transform duration-300 ease-out',
isDragging && 'transition-none'
)}
style={{
bottom: '75px',
height: position === 'full' ? 'calc(100% - 48px - 75px)' : 'calc(100% - 48px - 105px)',
transform: `translateY(${
position === 'half' ? `calc(55% + ${currentTranslate}px)` : `${Math.max(0, currentTranslate)}px`
})`,
}}
>
<div className="flex h-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing" {...handlers}>
<div className="h-1 w-11 rounded-full bg-indigo-300" />
</div>

<div className="relative flex shrink-0 items-center justify-center px-4 font-semibold">
<div className="text-center text-[15px] leading-6 text-indigo-700">랭킹</div>
<Dropdown
className="absolute right-4"
options={SORT_OPTIONS}
value={sort}
onChange={(value) => startTransition(() => setSort(value))}
/>
</div>
<BottomSheet resizable bottomOffset={75} halfTopOffset={105} fullTopOffset={48}>
{(position) => (
<>
<div className="relative flex shrink-0 items-center justify-center px-4 font-semibold">
<div className="text-center text-[15px] leading-6 text-indigo-700">랭킹</div>
<Dropdown
className="absolute right-4"
options={SORT_OPTIONS}
value={sort}
onChange={(value) => startTransition(() => setSort(value))}
/>
</div>

<div className="flex shrink-0 pt-2.5 pb-0.5">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => startTransition(() => setActiveTab(tab))}
className={clsx(
'flex-1 border-b-[1.4px] py-1.5 text-center text-[13px] font-semibold',
activeTab === tab ? 'border-blue-500 text-indigo-700' : 'border-transparent text-indigo-200'
)}
<div className="flex shrink-0 pt-2.5 pb-0.5">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => startTransition(() => setActiveTab(tab))}
className={clsx(
'flex-1 border-b-[1.4px] py-1.5 text-center text-[13px] font-semibold',
activeTab === tab ? 'border-blue-500 text-indigo-700' : 'border-transparent text-indigo-200'
)}
>
{tab}
</button>
))}
</div>
<div
className={clsx('min-h-0 overflow-hidden', isPending && 'opacity-60 transition-opacity')}
style={{
height: position === 'half' ? 'calc(45% - 70px)' : undefined,
flex: position === 'full' ? 1 : undefined,
}}
>
{tab}
</button>
))}
</div>
<div
className={clsx('min-h-0 overflow-hidden', isPending && 'opacity-60 transition-opacity')}
style={{
height: position === 'half' ? 'calc(45% - 70px)' : undefined,
flex: position === 'full' ? 1 : undefined,
}}
>
<RankingList type={TAB_TO_TYPE[activeTab]} sort={sort} />
</div>
</div>
<RankingList type={TAB_TO_TYPE[activeTab]} sort={sort} />
</div>
</>
)}
</BottomSheet>
</div>
);
}
Expand Down
23 changes: 22 additions & 1 deletion src/pages/User/MyPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import LayersIcon from '@/assets/svg/layers.svg';
import LogoutIcon from '@/assets/svg/logout.svg';
import UserSquareIcon from '@/assets/svg/user-square.svg';
import UserIcon from '@/assets/svg/user.svg';
import BottomModal from '@/components/common/BottomModal';
import Card from '@/components/common/Card';
import useBooleanState from '@/utils/hooks/useBooleanState';
import { useMyInfo } from '../Profile/hooks/useMyInfo';
import { useLogoutMutation } from './hooks/useLogout';

Expand All @@ -23,6 +25,8 @@ function MyPage() {
const navigate = useNavigate();
const { myInfo } = useMyInfo();
const { mutate: logout } = useLogoutMutation();
const { value: isOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false);

const handleClick = (to: string) => {
navigate(to);
};
Expand Down Expand Up @@ -81,13 +85,30 @@ function MyPage() {
<div className="text-[13px] leading-4 text-indigo-200">v1.0.0</div>
</div>
</div>
<button className="bg-indigo-0 flex items-center rounded-sm px-3 py-2" onClick={() => logout()}>
<button className="bg-indigo-0 flex items-center rounded-sm px-3 py-2" onClick={openModal}>
<div className="flex items-center gap-4">
<LogoutIcon />
<div className="text-sm leading-4 font-semibold">로그아웃</div>
</div>
</button>
</div>

<BottomModal isOpen={isOpen} onClose={closeModal}>
<div className="flex flex-col gap-10 px-8 pt-7 pb-4">
<div className="text-h3 text-center whitespace-pre-wrap">정말로 로그아웃 하시겠어요?</div>
<div>
<button
onClick={() => logout()}
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white"
>
로그아웃
</button>
<button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400">
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

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

The cancel button lacks semantic importance and could benefit from clearer styling. Currently the cancel button appears as plain text with only color differentiation, which may have insufficient contrast and could be easily missed. Consider adding a border or background to make it more visually distinct as an interactive element.

Suggested change
<button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400">
<button
type="button"
onClick={closeModal}
className="text-h3 w-full rounded-lg border border-indigo-200 bg-white py-3.5 text-center text-indigo-500"
>

Copilot uses AI. Check for mistakes.
취소
</button>
</div>
</div>
</BottomModal>
</div>
);
}
Expand Down
23 changes: 10 additions & 13 deletions src/pages/User/Profile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react';
import Modal from '@/components/common/Modal';
import BottomModal from '@/components/common/BottomModal';
import useBooleanState from '@/utils/hooks/useBooleanState';
import { useWithdrawMutation } from '../MyPage/hooks/useWithdraw';
import { useMyInfo } from './hooks/useMyInfo';
Expand Down Expand Up @@ -71,27 +71,24 @@ function Profile() {
수정 완료
</button>

<Modal isOpen={isOpen} onClose={closeModal}>
<div className="flex flex-col gap-5 p-5">
<div className="text-lg leading-8 font-bold whitespace-pre-wrap">
<BottomModal isOpen={isOpen} onClose={closeModal}>
<div className="flex flex-col gap-10 px-8 pt-7 pb-4">
<div className="text-h3 text-center whitespace-pre-wrap">
정말로 탈퇴하시겠어요?{'\n'}탈퇴 후 코넥트의 기능을 사용할 수 없어요
</div>
<div className="flex justify-between gap-3">
<button
onClick={closeModal}
className="bg-indigo-25 w-full rounded-lg py-3.5 text-center text-lg leading-7 font-bold text-indigo-400"
>
취소
</button>
<div>
<button
onClick={() => withdraw()}
className="w-full rounded-lg bg-indigo-700 py-3.5 text-center text-lg leading-7 font-bold text-white"
className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white"
>
탈퇴하기
</button>
<button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400">
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

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

The cancel button lacks semantic importance and could benefit from clearer styling. Currently the cancel button appears as plain text with only color differentiation, which may have insufficient contrast and could be easily missed. Consider adding a border or background to make it more visually distinct as an interactive element.

Suggested change
<button onClick={closeModal} className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400">
<button
onClick={closeModal}
className="text-h3 w-full rounded-lg py-3.5 text-center text-indigo-400 bg-indigo-50 border border-indigo-200"
>

Copilot uses AI. Check for mistakes.
취소
</button>
</div>
Comment on lines +79 to 89
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

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

The button ordering places the destructive primary action (탈퇴하기/withdraw) above the cancel action. This is contrary to common UX patterns where destructive actions should typically be placed in a less prominent position to prevent accidental clicks. Consider placing the cancel button above or making the destructive action less prominent visually.

Copilot uses AI. Check for mistakes.
</div>
</Modal>
</BottomModal>
</div>
);
}
Expand Down
Loading