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
Binary file added src/assets/image/3d-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/image/3d-file.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/image/3d-flag.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/image/boy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/image/chat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/image/folder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/svg/Chevron-left-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions src/assets/svg/add-photo-alternate.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/svg/close.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/svg/more-horizontal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/svg/role-selector-arrow-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions src/components/common/BottomModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ 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 { cn } from '@/utils/ts/cn';
import Portal from './Portal';

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

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

useClickTouchOutside(modalRef, onClose);
Expand All @@ -21,7 +23,7 @@ function BottomModal({ isOpen, onClose, children, className }: BottomModalProps)
return (
<Portal>
<div
className="fixed inset-0 z-100 bg-black/60"
className={cn('fixed inset-0 z-100 bg-black/60', overlayClassName)}
onClick={(e) => {
e.stopPropagation();
onClose();
Expand Down
57 changes: 47 additions & 10 deletions src/components/common/ToggleSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ interface ToggleSwitchProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
ariaLabel?: string;
layout?: 'vertical' | 'horizontal';
className?: string;
labelClassName?: string;
variant?: 'default' | 'manager';
}

function ToggleSwitch({
Expand All @@ -16,47 +19,81 @@ function ToggleSwitch({
enabled,
onChange,
disabled = false,
ariaLabel,
layout = 'vertical',
className,
labelClassName,
variant = 'default',
}: ToggleSwitchProps) {
const isManager = variant === 'manager';
const isHorizontal = layout === 'horizontal';

return (
<div
className={twMerge(
isHorizontal ? 'flex min-h-11 items-center justify-between gap-3' : 'flex flex-col items-center gap-1.5',
isManager
? 'flex items-center gap-1'
: isHorizontal
? 'flex min-h-11 items-center justify-between gap-3'
: 'flex flex-col items-center gap-1.5',
className
)}
>
<div className={twMerge(isHorizontal ? 'flex items-center gap-2.5' : 'flex flex-col items-center gap-1.5')}>
<div
className={twMerge(
isManager
? 'flex items-center gap-1'
: isHorizontal
? 'flex items-center gap-2.5'
: 'flex flex-col items-center gap-1.5'
)}
>
{Icon && (
<div className={enabled ? 'text-primary' : 'text-indigo-200'}>
<Icon />
</div>
)}
<span
className={twMerge(
isHorizontal ? 'text-sub2 transition-colors' : 'text-xs leading-3.5 font-medium',
enabled ? 'text-indigo-700' : 'text-indigo-300'
isManager
? 'text-[16px] leading-[1.6] font-medium text-[#5a6b7f]'
: isHorizontal
? 'text-sub2 transition-colors'
: 'text-xs leading-3.5 font-medium',
!isManager && (enabled ? 'text-indigo-700' : 'text-indigo-300'),
labelClassName
)}
>
{label}
</span>
</div>
<button
type="button"
aria-label={label}
aria-label={ariaLabel ?? label}
aria-pressed={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={twMerge(
'relative touch-manipulation rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-indigo-300 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60',
isHorizontal
? `h-7 w-12 border border-indigo-50 ${enabled ? 'bg-indigo-700' : 'bg-indigo-50'}`
: `h-5 w-9 ${enabled ? 'bg-primary' : 'bg-indigo-100'}`
isManager
? 'relative h-5 w-[37px] rounded-full transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
: 'relative touch-manipulation rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-indigo-300 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60',
isManager
? enabled
? 'bg-primary-500'
: 'bg-text-100'
: isHorizontal
? `h-7 w-12 border border-indigo-50 ${enabled ? 'bg-indigo-700' : 'bg-indigo-50'}`
: `h-5 w-9 ${enabled ? 'bg-primary' : 'bg-indigo-100'}`
Comment on lines +77 to +86
)}
>
{isHorizontal ? (
{isManager ? (
<span
className={twMerge(
'absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.15)] transition-transform',
enabled ? 'translate-x-[17px]' : 'translate-x-0'
)}
/>
Comment on lines +77 to +95
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

manager 변형이 터치/키보드 접근성을 너무 줄입니다.

이 분기에서는 실제 클릭 영역이 20x37 정도로 작아지고 focus-visible 표시도 사라집니다. 모바일 WebView에서는 누르기 어렵고, 키보드 포커스도 보이지 않아서 시각적인 트랙은 유지하더라도 버튼 hit area와 포커스 링은 따로 확보하는 편이 좋습니다.

As per coding guidelines, src/components/**: React 컴포넌트 컨벤션을 확인해주세요: - 접근성(aria-*, role, 키보드 탐색)이 적절히 처리되는지.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/ToggleSwitch.tsx` around lines 77 - 95, Update the
ToggleSwitch component's isManager branch so the clickable/touch target and
keyboard focus are preserved: keep the outer wrapper size consistent with
non-manager variant (add padding/touch-manipulation or an invisible larger
hit-area element), reintroduce focus-visible ring classes on the wrapper, and
ensure keyboard accessibility by adding role="switch", aria-checked based on
enabled, and tabIndex handling on the interactive element; adjust the inner knob
translation logic (the span that uses enabled ? 'translate-x-[17px]' :
'translate-x-0') to match the larger hit area so visual position remains
correct. References: isManager, enabled, isHorizontal, the wrapper className
conditional and the inner knob span.

) : isHorizontal ? (
<span
className={twMerge(
'absolute top-1/2 left-0.5 h-5 w-5 -translate-y-1/2 rounded-full bg-white shadow-[0_2px_4px_rgba(2,23,48,0.20)] transition-transform',
Expand Down
39 changes: 39 additions & 0 deletions src/components/layout/Header/components/ManagerHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useLocation } from 'react-router-dom';
import ChevronLeftIcon from '@/assets/svg/chevron-left.svg';
import { useManagedClub } from '@/pages/Manager/hooks/useManagedClubs';
import { useSmartBack } from '@/utils/hooks/useSmartBack';
import NotificationBell from './NotificationBell';

function ManagerHeaderBase({ title }: { title: string }) {
const smartBack = useSmartBack();

return (
<header className="fixed top-0 right-0 left-0 z-30 flex min-h-[var(--manager-header-height)] items-center justify-between rounded-b-3xl bg-white px-4 py-3 shadow-[0px_2px_2px_0px_rgba(0,0,0,0.05)]">
<div className="flex min-w-0 flex-1 items-center gap-1">
<button type="button" aria-label="뒤로가기" onClick={smartBack} className="shrink-0">
<ChevronLeftIcon />
</button>
<span className="text-sub1 truncate text-indigo-700">{title}</span>
</div>
<NotificationBell />
</header>
);
}

function ManagerHeaderWithClub({ clubId }: { clubId: number }) {
const { managedClub } = useManagedClub(clubId);
return <ManagerHeaderBase title={managedClub.clubName} />;
}

function ManagerHeader({ fallbackTitle }: { fallbackTitle: string }) {
const { pathname } = useLocation();
const match = pathname.match(/^\/mypage\/manager\/(\d+)$/);

if (match) {
return <ManagerHeaderWithClub clubId={Number(match[1])} />;
}

return <ManagerHeaderBase title={fallbackTitle} />;
}

export default ManagerHeader;
1 change: 1 addition & 0 deletions src/components/layout/Header/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const MANAGER_HEADER_HEIGHT = '63px';
4 changes: 2 additions & 2 deletions src/components/layout/Header/headerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export const HEADER_CONFIGS: HeaderConfig[] = [
match: (pathname) => pathname === '/signup/finish' || /^\/clubs\/\d+\/complete$/.test(pathname),
},
{
type: 'full',
match: (pathname) => /^\/mypage\/manager(?:\/[^/]+)?$/.test(pathname),
type: 'manager',
match: (pathname) => pathname.startsWith('/mypage/manager'),
},
{
type: 'signup',
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import ChatHeader from './components/ChatHeader';
import DefaultHeader from './components/DefaultHeader';
import InfoHeader from './components/InfoHeader';
import ManagerHeader from './components/ManagerHeader';
import ProfileHeader from './components/ProfileHeader';
import ScheduleHeader from './components/ScheduleHeader';
import { HEADER_CONFIGS, DEFAULT_HEADER_TYPE } from './headerConfig';
Expand All @@ -26,6 +27,7 @@ function Header() {
signup: ({ title, onBack }) => <DefaultHeader title={title} onBack={onBack} />,
council: ({ title }) => <DefaultHeader title={title} />,
default: ({ title }) => <DefaultHeader title={title} />,
manager: ({ title }) => <ManagerHeader fallbackTitle={title} />,
};

const onBack = headerType === 'signup' ? () => navigate('/') : undefined;
Expand Down
40 changes: 40 additions & 0 deletions src/components/layout/Header/routeTitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ export interface RouteTitle {
}

export const ROUTE_TITLES: RouteTitle[] = [
{
match: (pathname) => pathname === '/mypage/manager',
title: '동아리 관리',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/members$/.test(pathname),
title: '부원관리',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/members\/\d+\/application$/.test(pathname),
title: '지원서 보기',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/info$/.test(pathname),
title: '정보 수정하기',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/applications$/.test(pathname),
title: '지원자 관리',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/applications\/\d+$/.test(pathname),
title: '지원서 보기',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment$/.test(pathname),
title: '모집 공고 및 지원서 관리',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment\/write$/.test(pathname),
title: '모집 공고',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment\/form$/.test(pathname),
title: '지원서',
},
{
match: (pathname) => /^\/mypage\/manager\/\d+\/recruitment\/account$/.test(pathname),
title: '가입비',
},
Comment on lines +27 to +46
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

지원자 목록 페이지 타이틀이 빠져 있습니다.

src/App.tsx에는 /mypage/manager/:clubId/applications 라우트가 있는데 여기엔 매칭이 없습니다. 지금 headerConfig.ts가 manager 하위 경로 전체를 manager 헤더로 잡고 있어서, 이 페이지는 빈 제목으로 렌더링됩니다.

수정 예시
   {
+    match: (pathname) => /^\/mypage\/manager\/\d+\/applications$/.test(pathname),
+    title: '지원자 관리',
+  },
+  {
     match: (pathname) => /^\/mypage\/manager\/\d+\/applications\/\d+$/.test(pathname),
     title: '지원서 보기',
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/layout/Header/routeTitles.ts` around lines 23 - 42, Add a new
route entry in src/components/layout/Header/routeTitles.ts that matches the
applicants list path (e.g. a match function using
/^\/mypage\/manager\/\d+\/applications$/.test(pathname)) and set its title to
"지원자 목록"; insert this entry into the existing array of { match, title } objects
(near the other /mypage/manager entries) so the header renders the correct title
for /mypage/manager/:clubId/applications.

{
match: (pathname) => pathname.startsWith('/clubs/search'),
title: '동아리 검색',
Expand Down
3 changes: 2 additions & 1 deletion src/components/layout/Header/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export type HeaderType =
| 'full'
| 'signup'
| 'schedule'
| 'council';
| 'council'
| 'manager';

export interface HeaderConfig {
type: HeaderType;
Expand Down
16 changes: 10 additions & 6 deletions src/components/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Suspense } from 'react';
import { Suspense, type CSSProperties } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { cn } from '@/utils/ts/cn';
import BottomNav from './BottomNav';
import Header from './Header';
import { MANAGER_HEADER_HEIGHT } from './Header/constants';
import { HEADER_CONFIGS } from './Header/headerConfig';

interface LayoutProps {
Expand All @@ -15,19 +16,22 @@ export default function Layout({ showBottomNav = false, contentClassName }: Layo
const headerConfig = HEADER_CONFIGS.find((config) => config.match(pathname));
const headerType = headerConfig?.type;
const isInfoHeader = headerType === 'info';
const isManagerHeader = headerType === 'manager';
const hasHeader = headerType !== 'none';
const layoutStyle = {
height: 'var(--viewport-height)',
transform: 'translateY(var(--viewport-offset))',
'--manager-header-height': MANAGER_HEADER_HEIGHT,
} as CSSProperties;

return (
<div
className="fixed inset-0 flex flex-col overflow-hidden"
style={{ height: 'var(--viewport-height)', transform: 'translateY(var(--viewport-offset))' }}
>
<div className="fixed inset-0 flex flex-col overflow-hidden" style={layoutStyle}>
{hasHeader && <Header />}
<Suspense>
<main
className={cn(
'bg-background box-border flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
hasHeader && (isInfoHeader ? 'pt-15' : 'pt-11'),
hasHeader && (isInfoHeader ? 'pt-15' : isManagerHeader ? 'pt-[var(--manager-header-height)]' : 'pt-11'),
showBottomNav && 'pb-19',
contentClassName
)}
Expand Down
Loading
Loading